Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue3: Fix Renderer Disfunction #22994

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default {
decorators: [
(storyFn: PartialStoryFn, context: StoryContext) => {
return storyFn({
args: { object: { ...context.args } },
args: { object: context.args },
});
},
],
Expand Down
2 changes: 1 addition & 1 deletion code/lib/preview-api/template/stories/argTypes.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default {
// Compose all the argTypes into `object`, so the pre component only needs a single prop
decorators: [
(storyFn: PartialStoryFn, context: StoryContext) =>
storyFn({ args: { object: { ...context.argTypes } } }),
storyFn({ args: { object: context.argTypes } }),
],
argTypes: {
componentArg: { type: 'string' },
Expand Down
3 changes: 1 addition & 2 deletions code/lib/preview-api/template/stories/args.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ export default {
decorators: [
(storyFn: PartialStoryFn, context: StoryContext) => {
const { argNames } = context.parameters;
const args = { ...context.args };
const object = argNames ? pick(args, argNames) : args;
const object = argNames ? pick(context.args, argNames) : context.args;
return storyFn({ args: { object } });
},
],
Expand Down
8 changes: 5 additions & 3 deletions code/lib/preview-api/template/stories/decorators.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,15 @@ export const Hooks = {
// decorator that uses hooks
(storyFn: PartialStoryFn, context: StoryContext) => {
useEffect(() => {});

return storyFn({ args: { ...context.args, text: `story ${context.args['text']}` } });
},
// conditional decorator, runs before the above
(storyFn: PartialStoryFn, context: StoryContext) =>
context.args.condition
(storyFn: PartialStoryFn, context: StoryContext) => {
return context.args.condition
? storyFn()
: (context.originalStoryFn as ArgsStoryFn)(context.args, context),
: (context.originalStoryFn as ArgsStoryFn)(context.unmappedArgs, context);
},
],
args: {
text: 'text',
Expand Down
2 changes: 2 additions & 0 deletions code/lib/preview-api/template/stories/hooks.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export default {
};

export const UseState = {
// parameters: { inheritAttrs: true },
args: { label: 'Clicked 0 times' },
decorators: [
(story: PartialStoryFn) => {
const [count, setCount] = useState(0);
Expand Down
1 change: 1 addition & 0 deletions code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@
"node-gyp": "^9.3.1",
"nx": "16.2.1",
"nx-cloud": "16.0.5",
"playwright-core": "^1.35.0",
"prettier": "2.8.0",
"process": "^0.11.10",
"raf": "^3.4.1",
Expand Down
1 change: 1 addition & 0 deletions code/renderers/vue3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"vue-tsc": "latest"
},
"peerDependencies": {
"lodash": "*",
"vue": "^3.0.0"
},
"engines": {
Expand Down
65 changes: 29 additions & 36 deletions code/renderers/vue3/src/decorateStory.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Component, ComponentOptions, ConcreteComponent } from 'vue';
import type { ConcreteComponent, Component } from 'vue';
import { h } from 'vue';
import type { DecoratorFunction, LegacyStoryFn, StoryContext } from '@storybook/types';
import type { DecoratorFunction, StoryContext, LegacyStoryFn } from '@storybook/types';
import { sanitizeStoryContextUpdate } from '@storybook/preview-api';

import type { VueRenderer } from './types';

/*
Expand All @@ -11,59 +12,51 @@ import type { VueRenderer } from './types';
method on the ComponentOptions so end-users don't need to specify a "thunk" as a decorator.
*/

function normalizeFunctionalComponent(options: ConcreteComponent): ComponentOptions {
return typeof options === 'function' ? { render: options, name: options.name } : options;
}

function prepare(
rawStory: VueRenderer['storyResult'],
innerStory?: ConcreteComponent
): Component | null {
const story = rawStory as ComponentOptions;
if (story === null) {
return null;
const story: Component = rawStory;

if (!story || typeof story === 'function') {
return story;
}
if (typeof story === 'function') return story; // we don't need to wrap a functional component nor to convert it to a component options

if (innerStory) {
return {
// Normalize so we can always spread an object
...normalizeFunctionalComponent(story),
...story, // we don't to normalize the story if it's a functional component as it's already returned
components: { ...(story.components || {}), story: innerStory },
};
}

return {
render() {
return h(story);
},
};
const render = () => h(story);
return { render };
}

export function decorateStory(
storyFn: LegacyStoryFn<VueRenderer>,
decorators: DecoratorFunction<VueRenderer>[]
): LegacyStoryFn<VueRenderer> {
return decorators.reduce(
(decorated: LegacyStoryFn<VueRenderer>, decorator) => (context: StoryContext<VueRenderer>) => {
let story: VueRenderer['storyResult'] | undefined;

const decoratedStory: VueRenderer['storyResult'] = decorator((update) => {
const sanitizedUpdate = sanitizeStoryContextUpdate(update);
// update the args in a reactive way
if (update) sanitizedUpdate.args = Object.assign(context.args, sanitizedUpdate.args);
story = decorated({ ...context, ...sanitizedUpdate });
return story;
const decoratedStoryFn = decorators.reduce((decoratedFn, decorator) => {
const decoratedFunc = (context: StoryContext<VueRenderer>) =>
decorator((update) => {
const mergedContext = { ...context, ...sanitizeStoryContextUpdate(update) };
context.args = mergedContext.args;
const storyResult = decoratedFn(context);
return storyResult;
}, context);

if (!story) story = decorated(context);

if (decoratedStory === story) {
return story;
}
return (context: StoryContext<VueRenderer>) => {
const story = decoratedFunc(context);
const innerStory = () => h(story, context.args);
return prepare(story, innerStory) as VueRenderer['storyResult'];
};
}, storyFn);

const innerStory = () => h(story!);
return prepare(decoratedStory, innerStory) as VueRenderer['storyResult'];
},
(context) => prepare(storyFn(context)) as LegacyStoryFn<VueRenderer>
);
return (context: StoryContext<VueRenderer>) => {
const story = decoratedStoryFn(context);
story.inheritAttrs ??= context.parameters.inheritAttrs ?? false;
return prepare(story) as LegacyStoryFn<VueRenderer>;
};
}
50 changes: 29 additions & 21 deletions code/renderers/vue3/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
/* eslint-disable no-param-reassign */
import type { App } from 'vue';
import { createApp, h, reactive, isVNode, isReactive } from 'vue';
import type { ArgsStoryFn, RenderContext } from '@storybook/types';
import type { App, ConcreteComponent } from 'vue';
import { createApp, h, reactive, isReactive } from 'vue';
import type { RenderContext, ArgsStoryFn } from '@storybook/types';
import type { Args, StoryContext } from '@storybook/csf';

import type { StoryFnVueReturnType, StoryID, VueRenderer } from './types';
import type { VueRenderer, StoryID } from './types';

const slotsMap = new Map<
StoryID,
{
component?: Omit<ConcreteComponent<any>, 'props'>;
reactiveSlots?: Args;
}
>();

export const render: ArgsStoryFn<VueRenderer> = (props, context) => {
const { id, component: Component } = context;
Expand All @@ -14,7 +22,7 @@ export const render: ArgsStoryFn<VueRenderer> = (props, context) => {
);
}

return () => h(Component, props, generateSlots(context));
return h(Component, props, createOrUpdateSlots(context));
};

// set of setup functions that will be called when story is created
Expand Down Expand Up @@ -51,10 +59,9 @@ export function renderToCanvas(
// normally storyFn should be call once only in setup function,but because the nature of react and how storybook rendering the decorators
// we need to call here to run the decorators again
// i may wrap each decorator in memoized function to avoid calling it if the args are not changed
const element = storyFn(); // call the story function to get the root element with all the decorators
const args = getArgs(element, storyContext); // get args in case they are altered by decorators otherwise use the args from the context
storyFn(); // call the story function to run the decorators when the args are changed

updateArgs(existingApp.reactiveArgs, args);
updateArgs(existingApp.reactiveArgs, storyContext.args);
return () => {
teardown(existingApp.vueApp, canvasElement);
};
Expand All @@ -66,17 +73,15 @@ export function renderToCanvas(
setup() {
storyContext.args = reactive(storyContext.args);
const rootElement = storyFn(); // call the story function to get the root element with all the decorators
const args = getArgs(rootElement, storyContext); // get args in case they are altered by decorators otherwise use the args from the context
const { args } = storyContext; // get the args from the story context
const appState = {
vueApp,
reactiveArgs: reactive(args),
};
map.set(canvasElement, appState);

return () => {
// not passing args here as props
// treat the rootElement as a component without props
return h(rootElement);
return h(rootElement, appState.reactiveArgs);
};
},
});
Expand Down Expand Up @@ -107,15 +112,6 @@ function generateSlots(context: StoryContext<VueRenderer, Args>) {

return reactive(Object.fromEntries(slots));
}
/**
* get the args from the root element props if it is a vnode otherwise from the context
* @param element is the root element of the story
* @param storyContext is the story context
*/

function getArgs(element: StoryFnVueReturnType, storyContext: StoryContext<VueRenderer, Args>) {
return element.props && isVNode(element) ? element.props : storyContext.args;
}

/**
* update the reactive args
Expand Down Expand Up @@ -151,3 +147,15 @@ function teardown(
storybookApp?.unmount();
if (map.has(canvasElement)) map.delete(canvasElement);
}

function createOrUpdateSlots(context: StoryContext<VueRenderer, Args>) {
const { id: storyID, component } = context;
const slots = generateSlots(context);
if (slotsMap.has(storyID)) {
const app = slotsMap.get(storyID);
if (app?.reactiveSlots) updateArgs(app.reactiveSlots, slots);
return app?.reactiveSlots;
}
slotsMap.set(storyID, { component, reactiveSlots: slots });
return slots;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ const meta = {
// storybook render function is not a functional component. it returns a functional component or a component options
render: (args) => {
// create the slot contents as a functional components
const header = ({ title }: { title: string }) => h('h3', `${args.header} - Title: ${title}`);
const header = ({ title }: { title: string }) => h('h4', `${args.header} - Title: ${title}`);
const defaultSlot = () => h('p', `${args.default}`);
const footer = () => h('p', `${args.footer}`);
// vue render function is a functional components
return () =>
h('div', [
`Custom render uses a functional component, and passes slots to the component:`,
h('h3', 'Custom render returns a functional component'),
h(Reactivity, args, { header, default: defaultSlot, footer }),
]);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ const meta = {
components: {
Reactivity,
},
template: `<div>Custom render uses options api and binds args to data:
template: `<div>
<h3> Custom render returns options API Component</h3>
<Reactivity v-bind="args">
<template #header="{title}"><h3>{{ args.header }} - Title: {{ title }}</h3></template>
<template #header="{title}"><h4>{{ args.header }} - Title: {{ title }}</h4></template>
<template #default>{{ args.default }}</template>
<template #footer>{{ args.footer }} </template>
</Reactivity>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ const meta = {
default: { control: { type: 'text' } },
},
args: {
label: 'If you see this then the label arg was not reactive.',
default: 'If you see this then the default slot was not reactive.',
header: 'If you see this, the header slot was not reactive.', // this can be useless if you have custom render function that overrides the slot
footer: 'If you see this, the footer slot was not reactive.',
label: 'initial label',
default: 'initial default slot.',
header: 'initial header slot', // this can be useless if you have custom render function that overrides the slot
footer: 'initial footer slot.',
},
play: async ({ canvasElement, id, args }) => {
const channel = (globalThis as any).__STORYBOOK_ADDONS_CHANNEL__;
Expand Down Expand Up @@ -51,7 +51,9 @@ export const DecoratorFunctionalComponent: Story = {
decorators: [
(storyFn, context) => {
const story = storyFn();
return () => h('div', [h('h2', ['Decorator not using args']), [h(story)]]);
return () => {
return h('div', { style: 'border: 5px solid red' }, h(story));
};
},
],
};
Expand All @@ -60,8 +62,10 @@ export const DecoratorFunctionalComponentArgsFromContext: Story = {
decorators: [
(storyFn, context) => {
const story = storyFn();
return () =>
h('div', [h('h2', ['Decorator using args.label: ', context.args.label]), [h(story)]]);

return () => {
return h('div', { style: 'border: 5px solid blue' }, h(story, context.args));
};
},
],
};
Expand All @@ -70,7 +74,8 @@ export const DecoratorComponentOptions: Story = {
decorators: [
(storyFn, context) => {
return {
template: '<div><h2>Decorator not using args</h2><story/></div>',
components: { story: storyFn() },
template: `<div :style="{ border: '5px solid red' }" ><story/></div>`,
};
},
],
Expand All @@ -80,8 +85,9 @@ export const DecoratorComponentOptionsArgsFromData: Story = {
decorators: [
(storyFn, context) => {
return {
components: { story: storyFn() },
data: () => ({ args: context.args }),
template: '<div><h2>Decorator using args.label: {{args.label}}</h2><story/></div>',
template: `<div :style="{ border: '5px solid blue' }" ><story/></div>`,
};
},
],
Expand Down
Loading