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
30 changes: 20 additions & 10 deletions packages/vue/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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];
Expand Down
86 changes: 86 additions & 0 deletions packages/vue/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,37 @@ function writePage(relativePath: string, source: string): void {
writeFileSync(full, source);
}

type PatchProps = (comp: Record<string, unknown>, props: Record<string, unknown>) => 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<PatchProps> {
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<string, Array<(path: string) => Promise<void> | void>>();
plugin.configureServer?.({
Expand All @@ -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', '<template><h1>Home</h1></template>\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<string, unknown> = { 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', '<template><h1>Home</h1></template>\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<string, unknown> = {};

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 });
Expand Down