diff --git a/packages/vue/src/plugin-pages.ts b/packages/vue/src/plugin-pages.ts index 5897a94f6..a67a0b5c3 100644 --- a/packages/vue/src/plugin-pages.ts +++ b/packages/vue/src/plugin-pages.ts @@ -11,6 +11,24 @@ import type { PageScanResult, StaticPageDataBuildOptions } from 'void/pages'; const RENDERER_ID = 'virtual:void-pages-renderer'; const CLIENT_ENTRY_ID = 'virtual:void-pages-client'; +/** Runtime helper injected into generated SSR and client page bundles. */ +const PATCH_PROPS_RUNTIME = ` +function hasRuntimePropsDeclaration(comp) { + const declared = comp.props; + if (declared == null) return false; + if (Array.isArray(declared)) return declared.length > 0; + if (typeof declared === "object") return Object.keys(declared).length > 0; + return false; +} + +function patchProps(comp, props) { + if (hasRuntimePropsDeclaration(comp)) return; + if (comp.__voidPatched) return; + comp.props = Object.keys(props); + comp.__voidPatched = true; +} +`; + export type PrefetchConfig = { hoverDelay?: number; cacheFor?: number | string | [string, string]; @@ -279,11 +297,7 @@ import { serializePageData } from "void/pages-server"; ${generateComponentManifest(scan, pagesDir)} -function patchProps(comp, props) { - if (comp.__voidPatched) return; - comp.props = Object.keys(props); - comp.__voidPatched = true; -} +${PATCH_PROPS_RUNTIME} export async function renderPage(pageObj, assetTags) { const PageComponent = (await components[pageObj.component]()).default; @@ -354,11 +368,7 @@ const currentPageKey = ref(0); const routeUrl = ref(window.location.pathname + window.location.search + window.location.hash); const navigation = reactive(idleNavigationState()); -function patchProps(comp, props) { - if (comp.__voidPatched) return; - comp.props = Object.keys(props); - comp.__voidPatched = true; -} +${PATCH_PROPS_RUNTIME} function replaceRecord(target, source) { for (const key of Object.keys(target)) delete target[key]; diff --git a/packages/vue/test/unit/plugin-pages.test.ts b/packages/vue/test/unit/plugin-pages.test.ts index e67656490..9210f3df5 100644 --- a/packages/vue/test/unit/plugin-pages.test.ts +++ b/packages/vue/test/unit/plugin-pages.test.ts @@ -36,6 +36,37 @@ function writePage(relativePath: string, source: string): void { writeFileSync(full, source); } +type PatchProps = (comp: Record, props: Record) => void; + +function extractPatchProps(moduleSource: string): PatchProps { + const marker = 'function hasRuntimePropsDeclaration'; + const start = moduleSource.indexOf(marker); + if (start === -1) { + throw new Error('Expected patchProps runtime in generated module.'); + } + const afterPatchProps = moduleSource.indexOf('function replaceRecord', start); + const exportRender = moduleSource.indexOf('export async function renderPage', start); + const end = afterPatchProps === -1 ? exportRender : afterPatchProps; + if (end === -1) { + throw new Error('Expected patchProps runtime boundary in generated module.'); + } + const block = moduleSource.slice(start, end); + return new Function(`${block}; return patchProps;`)() as PatchProps; +} + +async function loadGeneratedPatchProps( + plugin: PagesPluginHooks, + virtualId: '\0virtual:void-pages-renderer' | '\0virtual:void-pages-client', +): Promise { + getHookHandler(plugin.config)({}, { command: 'build' }); + await getHookHandler(plugin.buildStart).call({}); + const source = await getHookHandler(plugin.load)(virtualId); + if (!source) { + throw new Error(`Expected generated module for ${virtualId}.`); + } + return extractPatchProps(source); +} + function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateModule = vi.fn()) { const handlers = new Map Promise | void>>(); plugin.configureServer?.({ @@ -54,6 +85,61 @@ function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateMo return handlers; } +describe('pagesPlugin generated patchProps', () => { + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('preserves runtime object props declarations in SSR and client bundles', async () => { + writePage('index.vue', '\n'); + + const plugin = pagesPlugin(PAGES_DIR).find((p) => p.name === 'void-vue:pages') as + | PagesPluginHooks + | undefined; + if (!plugin) { + throw new Error('Expected void-vue:pages plugin.'); + } + + const rendererPatchProps = await loadGeneratedPatchProps( + plugin, + '\0virtual:void-pages-renderer', + ); + const clientPatchProps = await loadGeneratedPatchProps(plugin, '\0virtual:void-pages-client'); + + const declared = { + title: { type: String, default: 'DEFAULT' }, + count: { type: Number, required: true }, + }; + const comp: Record = { props: { ...declared } }; + + rendererPatchProps(comp, { count: -1 }); + expect(comp.props).toEqual(declared); + expect(comp.__voidPatched).toBeUndefined(); + + clientPatchProps(comp, { count: -1 }); + expect(comp.props).toEqual(declared); + expect(comp.__voidPatched).toBeUndefined(); + }); + + it('registers prop keys when the component has no runtime props declaration', async () => { + writePage('index.vue', '\n'); + + const plugin = pagesPlugin(PAGES_DIR).find((p) => p.name === 'void-vue:pages') as + | PagesPluginHooks + | undefined; + if (!plugin) { + throw new Error('Expected void-vue:pages plugin.'); + } + + const patchProps = await loadGeneratedPatchProps(plugin, '\0virtual:void-pages-renderer'); + const comp: Record = {}; + + patchProps(comp, { count: 1, title: 'hi' }); + expect(comp.props).toEqual(['count', 'title']); + expect(comp.__voidPatched).toBe(true); + }); +}); + describe('pagesPlugin dev HMR', () => { afterEach(() => { rmSync(TMP, { recursive: true, force: true });