Skip to content

Commit 4ea5467

Browse files
committed
Preserve Vue loader props alongside model declarations.
1 parent b209501 commit 4ea5467

2 files changed

Lines changed: 84 additions & 2 deletions

File tree

‎packages/vue/src/plugin-pages.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,21 @@ function hasRuntimePropsDeclaration(comp) {
2222
}
2323
2424
function patchProps(comp, props) {
25-
if (hasRuntimePropsDeclaration(comp)) return;
2625
if (comp.__voidPatched) return;
27-
comp.props = Object.keys(props);
26+
const keys = Object.keys(props);
27+
if (hasRuntimePropsDeclaration(comp)) {
28+
const declared = comp.props;
29+
// defineModel() can declare runtime props even after typed loader props are stripped.
30+
const missing = keys.filter((key) =>
31+
Array.isArray(declared) ? !declared.includes(key) : !Object.hasOwn(declared, key)
32+
);
33+
if (missing.length === 0) return;
34+
comp.props = Array.isArray(declared)
35+
? [...declared, ...missing]
36+
: { ...Object.fromEntries(missing.map((key) => [key, null])), ...declared };
37+
} else {
38+
comp.props = keys;
39+
}
2840
comp.__voidPatched = true;
2941
}
3042
`;

‎packages/vue/test/unit/plugin-pages.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2+
import { stripTypeScriptTypes } from 'node:module';
23
import { join } from 'node:path';
34
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
5+
import * as Vue from 'vue';
6+
import { compileScript, parse } from 'vue/compiler-sfc';
7+
import { renderToString } from 'vue/server-renderer';
48
import { pagesPlugin } from '../../src/plugin-pages.ts';
59

610
const TMP = join(import.meta.dirname, '__tmp_pages_plugin__');
711
const PAGES_DIR = join(TMP, 'pages');
812

913
type HookWithHandler<T> = { handler: T };
1014
type PagesPluginHooks = {
15+
transform?: HookWithHandler<(code: string, id: string) => { code: string } | undefined>;
1116
buildStart?: (() => Promise<void>) | HookWithHandler<() => Promise<void>>;
1217
configureServer?: (server: unknown) => void;
1318
config?:
@@ -38,6 +43,22 @@ function writePage(relativePath: string, source: string): void {
3843

3944
type PatchProps = (comp: Record<string, unknown>, props: Record<string, unknown>) => void;
4045

46+
function compilePage(source: string): Vue.Component & Record<string, unknown> {
47+
const transform = pagesPlugin(PAGES_DIR).find((p) => p.name === 'void-vue:pages-transform') as
48+
| PagesPluginHooks
49+
| undefined;
50+
const transformed = getHookHandler(transform?.transform)(source, join(PAGES_DIR, 'index.vue'));
51+
const { descriptor } = parse(transformed?.code ?? source);
52+
const compiled = compileScript(descriptor, { id: 'test-page', inlineTemplate: true });
53+
// Evaluate the real SFC compiler output using the same Vue instance as the renderer.
54+
const code = stripTypeScriptTypes(compiled.content)
55+
.replace(/import \{([^}]+)\} from ['"]vue['"]/g, (_, names: string) => {
56+
return `const {${names.replace(/\s+as\s+/g, ':')}} = Vue;`;
57+
})
58+
.replace('export default', 'return');
59+
return new Function('Vue', code)(Vue) as Vue.Component & Record<string, unknown>;
60+
}
61+
4162
function extractPatchProps(moduleSource: string): PatchProps {
4263
const marker = 'function hasRuntimePropsDeclaration';
4364
const start = moduleSource.indexOf(marker);
@@ -138,6 +159,55 @@ describe('pagesPlugin generated patchProps', () => {
138159
expect(comp.props).toEqual(['count', 'title']);
139160
expect(comp.__voidPatched).toBe(true);
140161
});
162+
163+
for (const virtualId of [
164+
'\0virtual:void-pages-renderer',
165+
'\0virtual:void-pages-client',
166+
] as const) {
167+
it(`renders typed loader props alongside model defaults in ${virtualId.slice(1)}`, async () => {
168+
const source = `<script setup lang="ts">
169+
const props = defineProps<{ title: string }>();
170+
const draft = defineModel('draft', { type: String, default: 'Draft' });
171+
</script>
172+
<template><h1>{{ props.title }}</h1><p>{{ draft }}</p></template>`;
173+
writePage('index.vue', source);
174+
const plugin = pagesPlugin(PAGES_DIR).find((p) => p.name === 'void-vue:pages') as
175+
| PagesPluginHooks
176+
| undefined;
177+
if (!plugin) {
178+
throw new Error('Expected void-vue:pages plugin.');
179+
}
180+
const patchProps = await loadGeneratedPatchProps(plugin, virtualId);
181+
const comp = compilePage(source);
182+
const declared = comp.props;
183+
184+
patchProps(comp, { title: 'Hello' });
185+
186+
expect(await renderToString(Vue.createSSRApp(comp, { title: 'Hello' }))).toBe(
187+
'<!--[--><h1>Hello</h1><p>Draft</p><!--]-->',
188+
);
189+
expect(declared).toEqual({ draft: { type: String, default: 'Draft' }, draftModifiers: {} });
190+
expect(comp.props).toMatchObject(declared as object);
191+
});
192+
193+
it(`keeps array declarations when registering additional loader props in ${virtualId.slice(1)}`, async () => {
194+
writePage('index.vue', '<template><h1>Home</h1></template>');
195+
const plugin = pagesPlugin(PAGES_DIR).find((p) => p.name === 'void-vue:pages') as
196+
| PagesPluginHooks
197+
| undefined;
198+
if (!plugin) {
199+
throw new Error('Expected void-vue:pages plugin.');
200+
}
201+
const patchProps = await loadGeneratedPatchProps(plugin, virtualId);
202+
const declared = ['title', 'optional'];
203+
const comp = { props: declared };
204+
205+
patchProps(comp, { title: 'Hello', count: 1 });
206+
207+
expect(comp.props).toEqual(['title', 'optional', 'count']);
208+
expect(declared).toEqual(['title', 'optional']);
209+
});
210+
}
141211
});
142212

143213
describe('pagesPlugin dev HMR', () => {

0 commit comments

Comments
 (0)