From 927706f90c35311717406869cedadcabfb1cd5f4 Mon Sep 17 00:00:00 2001 From: alliasgher Date: Sun, 2 Aug 2026 17:03:40 +0500 Subject: [PATCH] Vue3: Only reload the preview when docgen changes on hot update The vue-component-meta plugin's handleHotUpdate sent a Vite full-reload on every file change, so editing a template, style or any component internal reloaded the whole preview iframe and lost transient story state such as open dialogs, form values, focus and scroll position. Cache the docgen emitted for each module in the transform hook and, on hot update, recompute it after refreshing the checker. When the docgen is unchanged, return undefined so Vue's own HMR handles the edit. The reload is kept for changes that actually alter the generated docs, and for files that have no docgen yet. --- .../src/plugins/vue-component-meta.test.ts | 80 +++++++- .../src/plugins/vue-component-meta.ts | 179 ++++++++++-------- 2 files changed, 180 insertions(+), 79 deletions(-) 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 83b7ff820863..c228b9141d61 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 plugin = await vueComponentMeta(); @@ -34,7 +36,33 @@ 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) { + const send = vi.fn(); + + return { + ctx: { + file, + read: async () => 'updated source', + server: { + ws: { send }, + moduleGraph: { invalidateModule: vi.fn() }, + }, + modules: [{ id: file }], + timestamp: 1, + }, + send, + }; } describe('vue-component-meta plugin', () => { @@ -196,6 +224,54 @@ 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' }); + }); + }); + 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 522979765d22..ae75c50bc80e 100644 --- a/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts +++ b/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts @@ -33,6 +33,88 @@ export async function vueComponentMeta(tsconfigPath = 'tsconfig.json'): Promise< const checker = await createVueComponentMetaChecker(tsconfigPath); + // Docgen last emitted per module, so hot updates can tell whether a change affects the docs. + const emittedMeta = new Map(); + + const computeMetaSources = async (id: string): Promise => { + const exportNames: string[] = []; + let componentsMeta: ComponentMeta[] = []; + + for (const name of checker.getExportNames(id)) { + try { + const meta = checker.getComponentMeta(id, name); + exportNames.push(name); + componentsMeta.push(meta); + } catch {} + } + + if (componentsMeta.length === 0) { + return []; + } + + componentsMeta = await applyTempFixForEventDescriptions(id, componentsMeta); + + const metaSources: MetaSource[] = []; + + componentsMeta.forEach((meta, index) => { + // filter out empty meta + const isEmpty = + !meta.props.length && !meta.events.length && !meta.slots.length && !meta.exposed.length; + + if (isEmpty || meta.type === TypeMeta.Unknown) { + return; + } + + const exportName = exportNames[index]; + + // we remove nested object schemas here since they are not used inside Storybook (we don't generate controls for object properties) + // and they can cause "out of memory" issues for large/complex schemas (e.g. HTMLElement) + // it also reduced the bundle size when running "storybook build" when such schemas are used + (['props', 'events', 'slots', 'exposed'] as const).forEach((key) => { + meta[key].forEach((value) => { + if (Array.isArray(value.schema)) { + value.schema.forEach((eventSchema) => removeNestedSchemas(eventSchema)); + } else { + removeNestedSchemas(value.schema); + } + }); + }); + + const exposed = + // the meta also includes duplicated entries in the "exposed" array with "on" + // prefix (e.g. onClick instead of click), so we need to filter them out here + meta.exposed + .filter((expose) => { + let nameWithoutOnPrefix = expose.name; + + if (nameWithoutOnPrefix.startsWith('on')) { + nameWithoutOnPrefix = lowercaseFirstLetter(expose.name.replace('on', '')); + } + + const hasEvent = meta.events.find((event) => event.name === nameWithoutOnPrefix); + return !hasEvent; + }) + // remove unwanted duplicated "$slots" expose + .filter((expose) => { + if (expose.name === '$slots') { + const slotNames = meta.slots.map((slot) => slot.name); + return !slotNames.every((slotName) => expose.type.includes(slotName)); + } + return true; + }); + + metaSources.push({ + exportName, + displayName: exportName === 'default' ? getFilenameWithoutExtension(id) : exportName, + ...meta, + exposed, + sourceFiles: id, + }); + }); + + return metaSources; + }; + return { name: 'storybook:vue-component-meta-plugin', transform: { @@ -44,83 +126,8 @@ export async function vueComponentMeta(tsconfigPath = 'tsconfig.json'): Promise< } try { - const exportNames: string[] = []; - let componentsMeta: ComponentMeta[] = []; - - for (const name of checker.getExportNames(id)) { - try { - const meta = checker.getComponentMeta(id, name); - exportNames.push(name); - componentsMeta.push(meta); - } catch {} - } - - if (componentsMeta.length === 0) { - return undefined; - } - - componentsMeta = await applyTempFixForEventDescriptions(id, componentsMeta); - - const metaSources: MetaSource[] = []; - - componentsMeta.forEach((meta, index) => { - // filter out empty meta - const isEmpty = - !meta.props.length && - !meta.events.length && - !meta.slots.length && - !meta.exposed.length; - - if (isEmpty || meta.type === TypeMeta.Unknown) { - return; - } - - const exportName = exportNames[index]; - - // we remove nested object schemas here since they are not used inside Storybook (we don't generate controls for object properties) - // and they can cause "out of memory" issues for large/complex schemas (e.g. HTMLElement) - // it also reduced the bundle size when running "storybook build" when such schemas are used - (['props', 'events', 'slots', 'exposed'] as const).forEach((key) => { - meta[key].forEach((value) => { - if (Array.isArray(value.schema)) { - value.schema.forEach((eventSchema) => removeNestedSchemas(eventSchema)); - } else { - removeNestedSchemas(value.schema); - } - }); - }); - - const exposed = - // the meta also includes duplicated entries in the "exposed" array with "on" - // prefix (e.g. onClick instead of click), so we need to filter them out here - meta.exposed - .filter((expose) => { - let nameWithoutOnPrefix = expose.name; - - if (nameWithoutOnPrefix.startsWith('on')) { - nameWithoutOnPrefix = lowercaseFirstLetter(expose.name.replace('on', '')); - } - - const hasEvent = meta.events.find((event) => event.name === nameWithoutOnPrefix); - return !hasEvent; - }) - // remove unwanted duplicated "$slots" expose - .filter((expose) => { - if (expose.name === '$slots') { - const slotNames = meta.slots.map((slot) => slot.name); - return !slotNames.every((slotName) => expose.type.includes(slotName)); - } - return true; - }); - - metaSources.push({ - exportName, - displayName: exportName === 'default' ? getFilenameWithoutExtension(id) : exportName, - ...meta, - exposed, - sourceFiles: id, - }); - }); + const metaSources = await computeMetaSources(id); + emittedMeta.set(id, JSON.stringify(metaSources)); // if there is no component meta, return undefined if (metaSources.length === 0) { @@ -168,6 +175,24 @@ export async function vueComponentMeta(tsconfigPath = 'tsconfig.json'): Promise< async handleHotUpdate({ file, read, server, modules, timestamp }) { const content = await read(); checker.updateFile(file, content); + + // The reload only exists to get fresh docgen into the preview. When the docgen is + // unchanged, let Vue's own HMR handle the edit so story state (open dialogs, form + // values, scroll position) survives. + if (emittedMeta.has(file)) { + try { + const nextMeta = JSON.stringify(await computeMetaSources(file)); + + if (nextMeta === emittedMeta.get(file)) { + return undefined; + } + + emittedMeta.set(file, nextMeta); + } catch { + // fall back to reloading if the meta could not be recomputed + } + } + // Invalidate modules manually const invalidatedModules = new Set();