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
142 changes: 113 additions & 29 deletions packages/vue/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<typeof loader>
const match = /\bdefineProps\s*</.exec(code);
if (!match) {
type BabelParserPlugins = SFCScriptCompileOptions['babelParserPlugins'];

function rewriteDefineProps(code: string, parserPlugins: BabelParserPlugins): string | null {
if (!code.includes('defineProps')) {
return null;
}

const start = match.index + match[0].length - 1; // position of '<'
let depth = 0;
let end = -1;
let newlineCount = 0;
for (let i = start; i < code.length; i++) {
const ch = code[i];
if (ch === '\n') {
newlineCount++;
} else if (ch === '<') {
depth++;
} else if (ch === '>') {
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<BabelParserPlugins>[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<BabelParserPlugins> = ['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<typeof babelParse>;
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. */
Expand All @@ -111,7 +191,11 @@ function hasNonIslandPages(dir: string): boolean {
return false;
}

export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): ReadonlyArray<Plugin> {
export function pagesPlugin(
pagesDir: string,
options?: PagesPluginOptions,
parserPlugins?: BabelParserPlugins,
): ReadonlyArray<Plugin> {
const normalizedPagesDir = normalizePath(pagesDir);
return [
{
Expand All @@ -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;
}
Expand All @@ -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;
};
},
},
Expand Down
10 changes: 5 additions & 5 deletions packages/vue/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ export function voidVue(options?: VoidVueOptions): Array<Plugin> {
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 }),
];
}
15 changes: 15 additions & 0 deletions packages/vue/test/integration/vue-pages-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = [];
Expand Down
74 changes: 72 additions & 2 deletions packages/vue/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<script setup lang="ts">
// defineProps<{ ignored: string }>();
const props = defineProps<{ title: string; note?: string }> /* keep */ (/* props */);
</script>
<template><div><h1>{{ props.title }}</h1><p>{{ props.note }}</p></div></template>`;
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: '<div><h1>A</h1><p></p></div>' },
{ props: { title: 'B', note: 'N' }, html: '<div><h1>B</h1><p>N</p></div>' },
];
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(`<script setup lang="ts">
type Props = { title: string } extends infer Result ? Result : never;
const props = defineProps<Props>() as Readonly<Props>;
</script>
<template><h1>{{ props.title }}</h1></template>`);
writePage('index.vue', '<template><h1>Home</h1></template>');
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('<h1>Hello</h1>');
});

it('compiles inferred props with Vue-supported TypeScript syntax and a JSX option', () => {
const source = `<script setup lang="ts">
function annotate(value: unknown) {}
@annotate class Example {}
type Props = { title: string } extends infer Result ? Result : never;
const props = <Readonly<Props>>defineProps<Props>();
</script>
<template><h1>{{ props.title }}</h1></template>`;
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(`<script setup lang="ts">
const props = withDefaults(defineProps<{ note?: string }>(), { note: 'Default note' });
</script>
<template><p>{{ props.note }}</p></template>`);
expect(await renderToString(Vue.createSSRApp(comp))).toBe('<p>Default note</p>');
});

for (const virtualId of [
'\0virtual:void-pages-renderer',
'\0virtual:void-pages-client',
Expand All @@ -187,7 +253,11 @@ const draft = defineModel('draft', { type: String, default: 'Draft' });
expect(await renderToString(Vue.createSSRApp(comp, { title: 'Hello' }))).toBe(
'<!--[--><h1>Hello</h1><p>Draft</p><!--]-->',
);
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);
});

Expand Down Expand Up @@ -222,7 +292,7 @@ describe('pagesPlugin transform paths', () => {
(p) => p.name === 'void-vue:pages-transform',
) as PagesPluginHooks;
const source = '<script setup lang="ts">defineProps<{ title: string }>();</script>';
const expected = '<script setup lang="ts">defineProps();</script>';
const expected = '<script setup lang="ts">defineProps(["title"]);</script>';
expect(getHookHandler(plugin.transform)(source, file)?.code).toBe(expected);

const ctx = { file, read: async () => source };
Expand Down
5 changes: 5 additions & 0 deletions playground/vue-pages/pages/optional-props/[id].server.ts
Original file line number Diff line number Diff line change
@@ -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' },
);
16 changes: 16 additions & 0 deletions playground/vue-pages/pages/optional-props/[id].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { Link } from '@void/vue';
import { ref } from 'vue';

const props = defineProps<{ title: string; note?: string }>();
const draft = ref('');
</script>

<template>
<section id="optional-props">
<h1>{{ props.title }}</h1>
<p>{{ props.note }}</p>
<label>Draft <input v-model="draft" /></label>
<Link href="/optional-props/second" preserve-state>Show note</Link>
</section>
</template>