Skip to content
Merged
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
121 changes: 119 additions & 2 deletions code/frameworks/vue3-vite/src/plugins/vue-component-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ function makeComponentMeta() {
};
}

async function getTransformHandler() {
type TransformHandler = (src: string, id: string) => Promise<{ code: string } | undefined>;

async function getPlugin() {
const { vueComponentMeta } = await import('./vue-component-meta.ts');
const { experimental_vueDocgenEngine } = await import('@storybook/vue3/preset');
const plugin = await vueComponentMeta(await experimental_vueDocgenEngine());
Expand All @@ -35,7 +37,39 @@ async function getTransformHandler() {
? plugin.transform
: (plugin.transform as { handler: (...args: unknown[]) => unknown }).handler;

return handler as (src: string, id: string) => Promise<{ code: string } | undefined>;
return {
transform: handler as TransformHandler,
handleHotUpdate: plugin.handleHotUpdate as (ctx: unknown) => Promise<unknown>,
};
}

async function getTransformHandler() {
return (await getPlugin()).transform;
}

/** Minimal Vite HMR context for exercising the handleHotUpdate hook. */
function makeHotUpdateContext(file: string, importerIds: string[] = []) {
const send = vi.fn();
const modules = [
{ id: file, importers: new Set(importerIds.map((id) => ({ id, importers: new Set() }))) },
];

return {
ctx: {
file,
read: async () => 'updated source',
server: {
ws: { send },
moduleGraph: {
invalidateModule: vi.fn(),
getModulesByFile: (requested: string) => (requested === file ? modules : []),
},
},
modules,
timestamp: 1,
},
send,
};
}

describe('vue-component-meta plugin', () => {
Expand Down Expand Up @@ -271,6 +305,89 @@ describe('vue-component-meta plugin', () => {
});
});

describe('hot update reloads (issue #35653)', () => {
const id = '/project/src/components/Tab.vue';
const src = `const _sfc_main = { name: 'Tab' };\nexport default _sfc_main;\n`;

beforeEach(() => {
mockChecker.getExportNames.mockReturnValue(['default']);
});

it('should not reload the preview when the docgen is unchanged', async () => {
const { transform, handleHotUpdate } = await getPlugin();
await transform(src, id);

const { ctx, send } = makeHotUpdateContext(id);
const result = await handleHotUpdate(ctx);

// returning undefined hands the update back to Vue's own HMR
expect(result).toBeUndefined();
expect(send).not.toHaveBeenCalled();
// the checker must still be kept in sync with the file on disk
expect(mockChecker.updateFile).toHaveBeenCalledWith(id, 'updated source');
});

it('should reload the preview when the docgen changed', async () => {
const { transform, handleHotUpdate } = await getPlugin();
await transform(src, id);

mockChecker.getComponentMeta.mockImplementation(() => ({
...makeComponentMeta(),
props: [{ name: 'newProp', schema: 'number' }],
}));

const { ctx, send } = makeHotUpdateContext(id);
const result = await handleHotUpdate(ctx);

expect(send).toHaveBeenCalledWith({ type: 'full-reload' });
expect(result).toEqual([]);
});

it('should reload the preview for a file that has no docgen yet', async () => {
const { handleHotUpdate } = await getPlugin();

const { ctx, send } = makeHotUpdateContext('/project/src/components/Other.vue');
await handleHotUpdate(ctx);

expect(send).toHaveBeenCalledWith({ type: 'full-reload' });
});

it("should reload the preview when the changed file alters an importer's docgen", async () => {
// props declared in their own module: the edited file has no docgen of its own, so judging it
// by its own meta would miss the change, but the component importing it is documented
const propsModule = '/project/src/components/tab-props.ts';
mockChecker.getExportNames.mockImplementation((forId: string) =>
forId === propsModule ? [] : ['default']
);

const { transform, handleHotUpdate } = await getPlugin();
await transform(src, id);
await transform(`export const tabProps = {};\n`, propsModule);

mockChecker.getComponentMeta.mockImplementation(() => ({
...makeComponentMeta(),
props: [{ name: 'newProp', schema: 'number' }],
}));

const { ctx, send } = makeHotUpdateContext(propsModule, [id]);
await handleHotUpdate(ctx);

expect(send).toHaveBeenCalledWith({ type: 'full-reload' });
});

it('should leave files it does not document to their own HMR', async () => {
const { handleHotUpdate } = await getPlugin();

const { ctx, send } = makeHotUpdateContext('/project/src/components/styles.css');
const result = await handleHotUpdate(ctx);

expect(result).toBeUndefined();
expect(send).not.toHaveBeenCalled();
// vue-component-meta warns and corrupts later lookups when fed a file it cannot parse
expect(mockChecker.updateFile).not.toHaveBeenCalled();
});
});

describe('re-export detection', () => {
it('should skip named re-exports like "export { Tab } from ..."', async () => {
const result = await transform(
Expand Down
70 changes: 65 additions & 5 deletions code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { logger } from 'storybook/internal/node-logger';
import { parseDefaultImports, parseLocalBindings } from 'storybook/internal/oxc-parser';

import MagicString from 'magic-string';
import type { ModuleNode, Plugin } from 'vite';
import type { ModuleNode, Plugin, ViteDevServer } from 'vite';

import type { experimental_vueDocgenEngine } from '@storybook/vue3/preset';

Expand All @@ -25,6 +26,35 @@ export async function vueComponentMeta(

const checker = await createVueComponentMetaChecker(tsconfigPath);

/** Docgen last emitted per module id, so a hot update can tell a docs-relevant edit from a template/style one. */
const emittedMeta = new Map<string, string>();

/**
* Ids whose docgen an edit to `file` can change: the file itself plus every module that
* imports it, since props types are routinely declared in a separate module.
*/
const collectDependentIds = (server: ViteDevServer, file: string): string[] => {
const ids: string[] = [];
const queue = [...(server.moduleGraph.getModulesByFile(file) ?? [])];
const seen = new Set<ModuleNode>();

while (queue.length > 0) {
const mod = queue.pop()!;

if (seen.has(mod)) {
continue;
}
seen.add(mod);

if (mod.id && emittedMeta.has(mod.id)) {
ids.push(mod.id);
}
queue.push(...mod.importers);
}

return ids;
};

return {
name: 'storybook:vue-component-meta-plugin',
transform: {
Expand All @@ -37,6 +67,7 @@ export async function vueComponentMeta(

try {
const metaSources = await collectComponentMetaSources(checker, id);
emittedMeta.set(id, JSON.stringify(metaSources));

// if there is no component meta, return undefined
if (metaSources.length === 0) {
Expand Down Expand Up @@ -87,11 +118,40 @@ export async function vueComponentMeta(
}
},
},
// handle hot updates to update the component meta on file changes
// reload the preview on hot updates that change the docgen it was built with
async handleHotUpdate({ file, read, server, modules, timestamp }) {
const content = await read();
checker.updateFile(file, content);
// Invalidate modules manually
// Files this plugin never documents keep their own HMR. Handing them to the checker also
// makes vue-component-meta warn ("languageId not found for ...") and poisons later lookups.
if (!filter(file)) {
return undefined;
}

checker.updateFile(file, await read());

const dependentIds = collectDependentIds(server, file);
// Without a cached baseline there is nothing to compare against, so assume the docs moved.
let docgenChanged = dependentIds.length === 0;

try {
for (const id of dependentIds) {
const nextMeta = JSON.stringify(await collectComponentMetaSources(checker, id));

if (nextMeta !== emittedMeta.get(id)) {
emittedMeta.set(id, nextMeta);
docgenChanged = true;
}
}
} catch (error) {
logger.debug(`Could not re-check Vue component meta for ${file}: ${String(error)}`);
docgenChanged = true;
}

// The reload exists only to get fresh docgen into the preview, so an edit that leaves every
// dependent module's docgen intact is left to Vue's HMR, keeping transient story state.
if (!docgenChanged) {
return undefined;
}

const invalidatedModules = new Set<ModuleNode>();

for (const mod of modules) {
Expand Down
Loading