diff --git a/code/frameworks/vue3-vite/src/plugins/vue-component-meta.test.ts b/code/frameworks/vue3-vite/src/plugins/vue-component-meta.test.ts index 08f9d5b25ce2..c3124df75f72 100644 --- a/code/frameworks/vue3-vite/src/plugins/vue-component-meta.test.ts +++ b/code/frameworks/vue3-vite/src/plugins/vue-component-meta.test.ts @@ -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()); @@ -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, + }; +} + +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', () => { @@ -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( diff --git a/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts b/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts index f44dc9d61c86..d5dacc62a22e 100644 --- a/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts +++ b/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts @@ -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'; @@ -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(); + + /** + * 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(); + + 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: { @@ -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) { @@ -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(); for (const mod of modules) {