From 88d8376f2ef450b6144adf40435f715bb9e19fec Mon Sep 17 00:00:00 2001 From: David Date: Tue, 10 Nov 2026 20:27:53 +0100 Subject: [PATCH] fix(vue): declare inline typed page props before rendering Refs #773 --- packages/vue/src/plugin-pages.ts | 142 ++++++++++++++---- packages/vue/src/plugin.ts | 10 +- .../test/integration/vue-pages-client.test.ts | 15 ++ packages/vue/test/unit/plugin-pages.test.ts | 74 ++++++++- .../pages/optional-props/[id].server.ts | 5 + .../vue-pages/pages/optional-props/[id].vue | 16 ++ 6 files changed, 226 insertions(+), 36 deletions(-) create mode 100644 playground/vue-pages/pages/optional-props/[id].server.ts create mode 100644 playground/vue-pages/pages/optional-props/[id].vue diff --git a/packages/vue/src/plugin-pages.ts b/packages/vue/src/plugin-pages.ts index 53d0541e0..5a8cbea6d 100644 --- a/packages/vue/src/plugin-pages.ts +++ b/packages/vue/src/plugin-pages.ts @@ -7,6 +7,7 @@ import type { PrefetchConfig, StaticPageDataBuildOptions, } from 'void/pages'; +import { babelParse, parse, type SFCScriptCompileOptions } from 'vue/compiler-sfc'; export type { PrefetchConfig } from 'void/pages'; @@ -54,37 +55,116 @@ function isVuePageFile(id: string, pagesDir: string): boolean { return basename !== 'layout.vue' && !basename.endsWith('.island.vue'); } -function stripDefinePropsTypeArgument(code: string): string | null { - // Find defineProps<...>( and strip the generic using bracket counting - // to handle nested generics like InferProps - const match = /\bdefineProps\s*') { - depth--; - if (depth === 0) { - end = i; - break; - } - } + const { scriptSetup } = parse(code).descriptor; + if (!scriptSetup || (scriptSetup.lang !== 'ts' && scriptSetup.lang !== 'tsx')) { + return null; + } + const isTsx = scriptSetup.lang === 'tsx'; + const pluginName = (plugin: NonNullable[number]) => + Array.isArray(plugin) ? plugin[0] : plugin; + const custom = (parserPlugins ?? []).filter((plugin) => isTsx || pluginName(plugin) !== 'jsx'); + const names = new Set(custom.map(pluginName)); + // Use Vue's TS parser defaults before applying caller options. + const plugins: NonNullable = ['typescript', 'explicitResourceManagement']; + if (!names.has('importAssertions') && !names.has('importAttributes')) { + plugins.push('importAttributes'); + } + if (!names.has('decorators')) { + plugins.push('decorators-legacy'); } - if (end === -1) { + if (isTsx) { + plugins.push('jsx'); + } + let script: ReturnType; + try { + script = babelParse(scriptSetup.content, { + sourceType: 'module', + plugins: [...plugins, ...custom], + }); + } catch { + // Let the Vue plugin report the original syntax error. return null; } - - return code.slice(0, start) + '\n'.repeat(newlineCount) + code.slice(end + 1); + for (const statement of script.program.body) { + const expressions = + statement.type === 'ExpressionStatement' + ? [statement.expression] + : statement.type === 'VariableDeclaration' + ? statement.declarations.map((declaration) => declaration.init) + : []; + for (let expression of expressions) { + // Vue unwraps TypeScript assertions before looking for macros. + while ( + expression?.type === 'TSAsExpression' || + expression?.type === 'TSTypeAssertion' || + expression?.type === 'TSNonNullExpression' || + expression?.type === 'TSInstantiationExpression' || + expression?.type === 'TSSatisfiesExpression' + ) { + expression = expression.expression; + } + const withDefaults = + expression?.type === 'CallExpression' && + expression.callee.type === 'Identifier' && + expression.callee.name === 'withDefaults' + ? expression + : undefined; + const call = withDefaults ? withDefaults.arguments[0] : expression; + if ( + call?.type !== 'CallExpression' || + call.callee.type !== 'Identifier' || + call.callee.name !== 'defineProps' + ) { + continue; + } + const parameters = call.typeParameters ?? call.typeArguments; + if ( + parameters?.type !== 'TSTypeParameterInstantiation' || + parameters.start == null || + parameters.end == null || + call.end == null + ) { + continue; + } + const type = parameters.params[0]; + const names = + type?.type === 'TSTypeLiteral' + ? type.members.map((member) => { + if (member.type !== 'TSPropertySignature' || member.computed) { + return; + } + if (member.key.type === 'Identifier') { + return member.key.name; + } + if (member.key.type === 'StringLiteral') { + return member.key.value; + } + }) + : undefined; + const literal = names?.every((name) => name !== undefined) === true; + if (withDefaults && literal) { + // Vue needs the typed macro to merge defaults into its declaration. + return null; + } + const offset = scriptSetup.loc.start.offset; + const start = offset + parameters.start; + const end = offset + parameters.end; + const close = offset + call.end - 1; + const newlines = code.slice(start, end).replace(/[^\n]/g, ''); + // Neutral declarations preserve optional keys without introducing coercion. + const declaration = literal && call.arguments.length === 0 ? JSON.stringify(names) : ''; + return ( + code.slice(0, start) + newlines + code.slice(end, close) + declaration + code.slice(close) + ); + } + } + return null; } /** Check if the pages directory has any non-island Vue page files. */ @@ -111,7 +191,11 @@ function hasNonIslandPages(dir: string): boolean { return false; } -export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): ReadonlyArray { +export function pagesPlugin( + pagesDir: string, + options?: PagesPluginOptions, + parserPlugins?: BabelParserPlugins, +): ReadonlyArray { const normalizedPagesDir = normalizePath(pagesDir); return [ { @@ -124,7 +208,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Rea if (!isVuePageFile(normalizePath(id), normalizedPagesDir)) { return; } - const transformed = stripDefinePropsTypeArgument(code); + const transformed = rewriteDefineProps(code, parserPlugins); if (!transformed) { return; } @@ -143,7 +227,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Rea // Vue reparses raw SFC source during HMR, so apply the same rewrite here. ctx.read = async () => { const code = await read(); - return stripDefinePropsTypeArgument(code) ?? code; + return rewriteDefineProps(code, parserPlugins) ?? code; }; }, }, diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts index 709002fac..98920e1be 100644 --- a/packages/vue/src/plugin.ts +++ b/packages/vue/src/plugin.ts @@ -15,11 +15,11 @@ export function voidVue(options?: VoidVueOptions): Array { const pagesDir = resolvePagesDir(root); return [ vue(options?.vue) as Plugin, - ...pagesPlugin(pagesDir, { - root, - viewTransitions: options?.viewTransitions, - prefetch: options?.prefetch, - }), + ...pagesPlugin( + pagesDir, + { root, viewTransitions: options?.viewTransitions, prefetch: options?.prefetch }, + options?.vue?.script?.babelParserPlugins, + ), ...islandsPlugin(pagesDir, { root }), ]; } diff --git a/packages/vue/test/integration/vue-pages-client.test.ts b/packages/vue/test/integration/vue-pages-client.test.ts index 62f75e4d8..37debcda7 100644 --- a/packages/vue/test/integration/vue-pages-client.test.ts +++ b/packages/vue/test/integration/vue-pages-client.test.ts @@ -137,6 +137,21 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full') } }); + it('renders an optional typed prop during navigation that preserves page state', async () => { + const page = await newPage(); + try { + await page.goto(BASE + '/optional-props/first'); + await waitForHydration(page); + await page.getByLabel('Draft').fill('Keep this draft'); + await page.getByRole('link', { name: 'Show note' }).click(); + await page.getByText('Optional note', { exact: true }).waitFor(); + expect(await page.locator('#optional-props').getAttribute('note')).toBeNull(); + expect(await page.getByLabel('Draft').inputValue()).toBe('Keep this draft'); + } finally { + await page.context().close(); + } + }); + it('hydrates without Vue warnings', async () => { const page = await newPage(); const warnings: Array = []; diff --git a/packages/vue/test/unit/plugin-pages.test.ts b/packages/vue/test/unit/plugin-pages.test.ts index 9c16617e7..114050da9 100644 --- a/packages/vue/test/unit/plugin-pages.test.ts +++ b/packages/vue/test/unit/plugin-pages.test.ts @@ -161,6 +161,72 @@ describe('pagesPlugin generated patchProps', () => { expect(comp.__voidPatched).toBe(true); }); + it('renders optional typed props independently of the first request', async () => { + const source = ` +`; + writePage('index.vue', source); + const plugin = pagesPlugin(PAGES_DIR).find( + (p) => p.name === 'void-vue:pages', + ) as PagesPluginHooks; + const patchProps = await loadGeneratedPatchProps(plugin, '\0virtual:void-pages-renderer'); + const cases = [ + { props: { title: 'A' }, html: '

A

' }, + { props: { title: 'B', note: 'N' }, html: '

B

N

' }, + ]; + for (let order = 0; order < 8; order++) { + const comp = compilePage(source); + for (let step = 0; step < 3; step++) { + const { props, html } = cases[(order >> step) & 1]; + patchProps(comp, props); + expect(await renderToString(Vue.createSSRApp(comp, props))).toBe(html); + } + } + }); + + it('keeps the legacy runtime path for unresolved types wrapped in an assertion', async () => { + const comp = compilePage(` +`); + writePage('index.vue', ''); + const plugin = pagesPlugin(PAGES_DIR).find( + (p) => p.name === 'void-vue:pages', + ) as PagesPluginHooks; + const patchProps = await loadGeneratedPatchProps(plugin, '\0virtual:void-pages-renderer'); + patchProps(comp, { title: 'Hello' }); + expect(await renderToString(Vue.createSSRApp(comp, { title: 'Hello' }))).toBe('

Hello

'); + }); + + it('compiles inferred props with Vue-supported TypeScript syntax and a JSX option', () => { + const source = ` +`; + const transform = pagesPlugin(PAGES_DIR, undefined, ['jsx']).find( + (p) => p.name === 'void-vue:pages-transform', + ) as PagesPluginHooks; + const result = getHookHandler(transform.transform)(source, join(PAGES_DIR, 'index.vue')); + const { descriptor } = parse(result?.code ?? source); + expect(() => + compileScript(descriptor, { id: 'test-page', babelParserPlugins: ['jsx'] }), + ).not.toThrow(); + }); + + it('retains Vue defaults for inline typed props', async () => { + const comp = compilePage(` +`); + expect(await renderToString(Vue.createSSRApp(comp))).toBe('

Default note

'); + }); + for (const virtualId of [ '\0virtual:void-pages-renderer', '\0virtual:void-pages-client', @@ -187,7 +253,11 @@ const draft = defineModel('draft', { type: String, default: 'Draft' }); expect(await renderToString(Vue.createSSRApp(comp, { title: 'Hello' }))).toBe( '

Hello

Draft

', ); - expect(declared).toEqual({ draft: { type: String, default: 'Draft' }, draftModifiers: {} }); + expect(declared).toEqual({ + title: null, + draft: { type: String, default: 'Draft' }, + draftModifiers: {}, + }); expect(comp.props).toMatchObject(declared as object); }); @@ -222,7 +292,7 @@ describe('pagesPlugin transform paths', () => { (p) => p.name === 'void-vue:pages-transform', ) as PagesPluginHooks; const source = ''; - const expected = ''; + const expected = ''; expect(getHookHandler(plugin.transform)(source, file)?.code).toBe(expected); const ctx = { file, read: async () => source }; diff --git a/playground/vue-pages/pages/optional-props/[id].server.ts b/playground/vue-pages/pages/optional-props/[id].server.ts new file mode 100644 index 000000000..9d0ea2a7f --- /dev/null +++ b/playground/vue-pages/pages/optional-props/[id].server.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'void'; + +export const loader = defineHandler<{ title: string; note?: string }>((c) => + c.req.param('id') === 'second' ? { title: 'Second', note: 'Optional note' } : { title: 'First' }, +); diff --git a/playground/vue-pages/pages/optional-props/[id].vue b/playground/vue-pages/pages/optional-props/[id].vue new file mode 100644 index 000000000..319cb15b3 --- /dev/null +++ b/playground/vue-pages/pages/optional-props/[id].vue @@ -0,0 +1,16 @@ + + +