From b82fcfaedee1ab266868fce3540b054f2f2fbbef Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 12:03:39 +0800 Subject: [PATCH] fix(pages): deferred props, action responses and dev rescan errors - Dev: a page scan error during a rescan is logged and the last good scan is kept, instead of an unhandled rejection that stops the dev server. - A deferred group resolves only its own keys: a one-key group takes the raw value, a larger group takes each key from the object. A value whose object has a key named like another deferred prop no longer lands there. - Prefetch and prerender payloads send a failed deferred group as an error that the client rejects, instead of a silent null. - A deferred resolver that returns undefined resolves to null on client navigation, as it already did on a full page load. - After an action, a loader that returns a redirect or error Response answers with that Response, as on a GET, instead of rendering {} props. - Only a withValidator issues body in a 400 becomes field errors. Other 400 responses from an action pass through unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react/src/plugin-pages.ts | 11 +- packages/react/test/unit/plugin-pages.test.ts | 46 ++++- packages/solid/src/plugin-pages.ts | 11 +- packages/solid/test/unit/plugin-pages.test.ts | 46 ++++- packages/svelte/src/plugin-pages.ts | 11 +- .../svelte/test/unit/plugin-pages.test.ts | 46 ++++- packages/void/src/pages/islands-plugin.ts | 29 ++- packages/void/src/pages/navigation-engine.ts | 44 ++--- packages/void/src/pages/protocol.ts | 79 +++++--- packages/void/test/unit/pages-client.test.ts | 157 ++++++++++++++++ .../test/unit/pages-islands-rescan.test.ts | 64 +++++++ .../void/test/unit/pages-protocol.test.ts | 172 ++++++++++++++++++ packages/vue/src/plugin-pages.ts | 11 +- packages/vue/test/unit/plugin-pages.test.ts | 48 ++++- 14 files changed, 699 insertions(+), 76 deletions(-) create mode 100644 packages/void/test/unit/pages-islands-rescan.test.ts diff --git a/packages/react/src/plugin-pages.ts b/packages/react/src/plugin-pages.ts index 6315ff9b3..919ecc9b3 100644 --- a/packages/react/src/plugin-pages.ts +++ b/packages/react/src/plugin-pages.ts @@ -151,7 +151,16 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu if (event === 'change' && !normalizedPath.endsWith('.md')) { return; } - await runRescan(); + try { + await runRescan(); + } catch (error) { + // Keep the last good scan; the next change rescans. + server.config.logger.error(error instanceof Error ? error.message : String(error), { + error: error instanceof Error ? error : undefined, + timestamp: true, + }); + return; + } invalidatePageModules(server); }; diff --git a/packages/react/test/unit/plugin-pages.test.ts b/packages/react/test/unit/plugin-pages.test.ts index bb615b35e..f2f945388 100644 --- a/packages/react/test/unit/plugin-pages.test.ts +++ b/packages/react/test/unit/plugin-pages.test.ts @@ -1,6 +1,6 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { pagesPlugin } from '../../src/plugin-pages.ts'; const TMP = join(import.meta.dirname, '__tmp_pages_plugin__'); @@ -45,7 +45,7 @@ function writePage(relativePath: string, source: string): void { writeFileSync(full, source); } -function setupWatcher(plugin: PagesPluginHooks) { +function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateModule = vi.fn()) { const handlers = new Map Promise | void>>(); plugin.configureServer?.({ watcher: { @@ -55,7 +55,10 @@ function setupWatcher(plugin: PagesPluginHooks) { handlers.set(event, eventHandlers); }, }, - environments: {}, + environments: { + client: { moduleGraph: { getModuleById: (id: string) => ({ id }), invalidateModule } }, + }, + config: { logger: { error: logError } }, }); return handlers; } @@ -104,4 +107,41 @@ describe('pagesPlugin dev config', () => { expect(renderer).toContain('"test": () => import'); }); + + it('logs a page scan error from the watcher and keeps the last scan', async () => { + writePage('index.tsx', 'export default function Index() { return

Home

; }\n'); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.buildStart).call({}); + const logError = vi.fn(); + const invalidateModule = vi.fn(); + const handlers = setupWatcher(plugin, logError, invalidateModule); + const emitAdd = async (relativePath: string) => { + for (const handler of handlers.get('add') ?? []) { + await expect(handler(join(PAGES_DIR, relativePath))).resolves.toBeUndefined(); + } + }; + + writePage('about.md', '---\nlayout: docs\n---\n# About\n'); + await emitAdd('about.md'); + + expect(logError).toHaveBeenCalledTimes(1); + expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'"); + expect(invalidateModule).not.toHaveBeenCalled(); + const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + expect(renderer).toContain('"index": () => import'); + expect(renderer).not.toContain('"about"'); + + writePage( + '_layouts/docs.tsx', + 'export default function Docs({ children }) { return
{children}
; }\n', + ); + await emitAdd('_layouts/docs.tsx'); + + expect(logError).toHaveBeenCalledTimes(1); + expect(invalidateModule).toHaveBeenCalled(); + const recovered = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + expect(recovered).toContain('"about"'); + }); }); diff --git a/packages/solid/src/plugin-pages.ts b/packages/solid/src/plugin-pages.ts index 5ec15f82a..1c4472b97 100644 --- a/packages/solid/src/plugin-pages.ts +++ b/packages/solid/src/plugin-pages.ts @@ -128,7 +128,16 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu if (event === 'change' && !normalizedPath.endsWith('.md')) { return; } - await runRescan(); + try { + await runRescan(); + } catch (error) { + // Keep the last good scan; the next change rescans. + server.config.logger.error(error instanceof Error ? error.message : String(error), { + error: error instanceof Error ? error : undefined, + timestamp: true, + }); + return; + } invalidatePageModules(server); }; diff --git a/packages/solid/test/unit/plugin-pages.test.ts b/packages/solid/test/unit/plugin-pages.test.ts index f65a59e0f..db8407a32 100644 --- a/packages/solid/test/unit/plugin-pages.test.ts +++ b/packages/solid/test/unit/plugin-pages.test.ts @@ -1,6 +1,6 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { pagesPlugin } from '../../src/plugin-pages.ts'; const TMP = join(import.meta.dirname, '__tmp_pages_plugin__'); @@ -36,7 +36,7 @@ function writePage(relativePath: string, source: string): void { writeFileSync(full, source); } -function setupWatcher(plugin: PagesPluginHooks) { +function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateModule = vi.fn()) { const handlers = new Map Promise | void>>(); plugin.configureServer?.({ watcher: { @@ -46,7 +46,10 @@ function setupWatcher(plugin: PagesPluginHooks) { handlers.set(event, eventHandlers); }, }, - environments: {}, + environments: { + client: { moduleGraph: { getModuleById: (id: string) => ({ id }), invalidateModule } }, + }, + config: { logger: { error: logError } }, }); return handlers; } @@ -74,4 +77,41 @@ describe('pagesPlugin dev HMR', () => { expect(renderer).toContain('"test": () => import'); }); + + it('logs a page scan error from the watcher and keeps the last scan', async () => { + writePage('index.tsx', 'export default function Index() { return

Home

; }\n'); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.buildStart).call({}); + const logError = vi.fn(); + const invalidateModule = vi.fn(); + const handlers = setupWatcher(plugin, logError, invalidateModule); + const emitAdd = async (relativePath: string) => { + for (const handler of handlers.get('add') ?? []) { + await expect(handler(join(PAGES_DIR, relativePath))).resolves.toBeUndefined(); + } + }; + + writePage('about.md', '---\nlayout: docs\n---\n# About\n'); + await emitAdd('about.md'); + + expect(logError).toHaveBeenCalledTimes(1); + expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'"); + expect(invalidateModule).not.toHaveBeenCalled(); + const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + expect(renderer).toContain('"index": () => import'); + expect(renderer).not.toContain('"about"'); + + writePage( + '_layouts/docs.tsx', + 'export default function Docs(props) { return
{props.children}
; }\n', + ); + await emitAdd('_layouts/docs.tsx'); + + expect(logError).toHaveBeenCalledTimes(1); + expect(invalidateModule).toHaveBeenCalled(); + const recovered = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + expect(recovered).toContain('"about"'); + }); }); diff --git a/packages/svelte/src/plugin-pages.ts b/packages/svelte/src/plugin-pages.ts index 83ce14bc5..8dec2b458 100644 --- a/packages/svelte/src/plugin-pages.ts +++ b/packages/svelte/src/plugin-pages.ts @@ -135,7 +135,16 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu if (event === 'change' && !normalizedPath.endsWith('.md')) { return; } - await runRescan(); + try { + await runRescan(); + } catch (error) { + // Keep the last good scan; the next change rescans. + server.config.logger.error(error instanceof Error ? error.message : String(error), { + error: error instanceof Error ? error : undefined, + timestamp: true, + }); + return; + } invalidatePageModules(server); }; diff --git a/packages/svelte/test/unit/plugin-pages.test.ts b/packages/svelte/test/unit/plugin-pages.test.ts index f79149045..faabb40ca 100644 --- a/packages/svelte/test/unit/plugin-pages.test.ts +++ b/packages/svelte/test/unit/plugin-pages.test.ts @@ -1,6 +1,6 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { pagesPlugin } from '../../src/plugin-pages.ts'; const TMP = join(import.meta.dirname, '__tmp_pages_plugin__'); @@ -36,7 +36,7 @@ function writePage(relativePath: string, source: string): void { writeFileSync(full, source); } -function setupWatcher(plugin: PagesPluginHooks) { +function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateModule = vi.fn()) { const handlers = new Map Promise | void>>(); plugin.configureServer?.({ watcher: { @@ -46,7 +46,10 @@ function setupWatcher(plugin: PagesPluginHooks) { handlers.set(event, eventHandlers); }, }, - environments: {}, + environments: { + client: { moduleGraph: { getModuleById: (id: string) => ({ id }), invalidateModule } }, + }, + config: { logger: { error: logError } }, }); return handlers; } @@ -75,6 +78,43 @@ describe('pagesPlugin dev HMR', () => { expect(renderer).toContain('"test": () => import'); }); + it('logs a page scan error from the watcher and keeps the last scan', async () => { + writePage('index.svelte', '

Home

\n'); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.buildStart).call({}); + const logError = vi.fn(); + const invalidateModule = vi.fn(); + const handlers = setupWatcher(plugin, logError, invalidateModule); + const emitAdd = async (relativePath: string) => { + for (const handler of handlers.get('add') ?? []) { + await expect(handler(join(PAGES_DIR, relativePath))).resolves.toBeUndefined(); + } + }; + + writePage('about.md', '---\nlayout: docs\n---\n# About\n'); + await emitAdd('about.md'); + + expect(logError).toHaveBeenCalledTimes(1); + expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'"); + expect(invalidateModule).not.toHaveBeenCalled(); + const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + expect(renderer).toContain('"index": () => import'); + expect(renderer).not.toContain('"about"'); + + writePage( + '_layouts/docs.svelte', + '\n
{@render children()}
\n', + ); + await emitAdd('_layouts/docs.svelte'); + + expect(logError).toHaveBeenCalledTimes(1); + expect(invalidateModule).toHaveBeenCalled(); + const recovered = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + expect(recovered).toContain('"about"'); + }); + it('excludes @void/svelte from the void worker optimizer in dev', () => { writePage('index.svelte', '

Home

\n'); diff --git a/packages/void/src/pages/islands-plugin.ts b/packages/void/src/pages/islands-plugin.ts index 2aab46682..bf0c7ce45 100644 --- a/packages/void/src/pages/islands-plugin.ts +++ b/packages/void/src/pages/islands-plugin.ts @@ -327,16 +327,25 @@ export function watchPagesForRescan( } debounceTimer = setTimeout(() => { debounceTimer = null; - pending = scanPages(root, { pagesDir }).then((pageScan) => { - onRescan(pageScan); - for (const env of Object.values(server.environments)) { - const mod = env.moduleGraph.getModuleById('\0' + ISLANDS_RENDERER_ID); - if (mod) { - env.moduleGraph.invalidateModule(mod); - } - } - pending = null; - }); + pending = scanPages(root, { pagesDir }) + .then( + (pageScan) => { + onRescan(pageScan); + for (const env of Object.values(server.environments)) { + const mod = env.moduleGraph.getModuleById('\0' + ISLANDS_RENDERER_ID); + if (mod) { + env.moduleGraph.invalidateModule(mod); + } + } + }, + () => { + // The adapter pages plugin runs the same scan on the same events and + // logs its error. Keep the last good scan until the next change. + }, + ) + .finally(() => { + pending = null; + }); }, 50); }; diff --git a/packages/void/src/pages/navigation-engine.ts b/packages/void/src/pages/navigation-engine.ts index 71cb582f3..75672de65 100644 --- a/packages/void/src/pages/navigation-engine.ts +++ b/packages/void/src/pages/navigation-engine.ts @@ -293,8 +293,14 @@ export async function createPageNavigationEngine( } for (const [key, group] of Object.entries(pageData.deferredKeys) as Array<[string, string]>) { - const ref = adapter.createDeferred(); - const value = resolved ? adapter.resolveDeferred(ref, pageData.props[key]) : ref; + let value = adapter.createDeferred(); + if (resolved) { + const error = pageData.deferredErrors?.[group]; + value = + error === undefined + ? adapter.resolveDeferred(value, pageData.props[key]) + : adapter.rejectDeferred(value, error); + } this.keyRefs[key] = value; this.props[key] = value; (this.groups[group] ??= []).push(key); @@ -340,20 +346,7 @@ export async function createPageNavigationEngine( } if (data !== undefined) { - if (typeof data === 'object' && data !== null && !Array.isArray(data)) { - let resolvedAny = false; - for (const [key, value] of Object.entries(data as Record)) { - if (this.keyRefs[key]) { - this.resolveKey(key, value); - resolvedAny = true; - } - } - if (!resolvedAny) { - this.resolveGroup(group, data); - } - } else { - this.resolveGroup(group, data); - } + this.resolveGroup(group, data); } else if (error !== undefined) { this.rejectGroup(group, typeof error === 'string' ? error : String(error)); } @@ -388,7 +381,6 @@ export async function createPageNavigationEngine( private retainOnly(keys: Set, props: Record): void { const keyRefs: Record = {}; - const groups: Record> = {}; for (const [key, ref] of Object.entries(this.keyRefs)) { if (keys.has(key)) { keyRefs[key] = ref; @@ -397,22 +389,24 @@ export async function createPageNavigationEngine( adapter.rejectDeferred(ref, 'Navigation disposed before deferred props resolved'); } } - for (const [group, groupKeys] of Object.entries(this.groups)) { - const preservedGroupKeys = groupKeys.filter((key) => keys.has(key)); - if (preservedGroupKeys.length > 0) { - groups[group] = preservedGroupKeys; - } - } + // Groups keep every key so a group resolves with its original shape. + // Keys dropped from keyRefs are skipped when the group settles. this.props = props; this.keyRefs = keyRefs; - this.groups = groups; adapter.onDeferredUpdate?.(this.props); } + /** A one-key group takes the raw value; a larger group takes each key from the object. */ private resolveGroup(group: string, data: unknown): void { const keys = this.groups[group] ?? (this.keyRefs[group] ? [group] : []); + if (keys.length === 1) { + this.resolveKey(keys[0]!, data); + return; + } + const values = + typeof data === 'object' && data !== null ? (data as Record) : {}; for (const key of keys) { - this.resolveKey(key, data); + this.resolveKey(key, values[key]); } } diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 1576e7771..2ddd4aac3 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -34,6 +34,8 @@ export type PageObject = { head?: HeadDescriptor; deferred?: Array; deferredKeys?: Record; + /** Error message per failed deferred group in a prefetch payload. */ + deferredErrors?: Record; }; export type PageRenderResult = string | ReadableStream; @@ -270,6 +272,30 @@ async function extractProps(result: unknown): Promise> { return (result as Record) ?? {}; } +/** + * Run a page loader. Responses from convertReturnValue (withValidator/middleware + * paths) always flow through to extractProps — they wrap plain return values + * (objects as 200 JSON, null/undefined as 204, strings as text/html) and must be + * unwrapped as page props. + * + * User-returned Responses: pass through errors/redirects (>= 300) and non-JSON + * 200s (file downloads, streams). JSON 200s are still extracted as props for + * backward compat with loaders using c.json(). + */ +async function runLoader( + handler: NonNullable, + c: CloudContext, +): Promise> { + const result = await handler(c); + if (result instanceof Response && !isConvertedResponse(result)) { + const contentType = result.headers.get('content-type') || ''; + if (result.status >= 300 || !contentType.includes('application/json')) { + return result; + } + } + return extractProps(result); +} + /** Flatten withValidator's { issues: [{ slot, issues: [{ message, path }] }] } into { field: message } */ export function flattenValidationErrors(body: unknown): Record { const errors: Record = {}; @@ -410,22 +436,11 @@ export async function handlePageGet( // Run loader and check for redirect/error responses before extracting props let props: Record = {}; if (handler) { - const result = await handler(c); - // Responses from convertReturnValue (withValidator/middleware paths) always - // flow through to extractProps — they wrap plain return values (objects as - // 200 JSON, null/undefined as 204, strings as text/html) and must be - // unwrapped as page props. - // - // User-returned Responses: pass through errors/redirects (>= 300) and - // non-JSON 200s (file downloads, streams). JSON 200s are still extracted - // as props for backward compat with loaders using c.json(). - if (result instanceof Response && !isConvertedResponse(result)) { - const contentType = result.headers.get('content-type') || ''; - if (result.status >= 300 || !contentType.includes('application/json')) { - return result; - } + const loaded = await runLoader(handler, c); + if (loaded instanceof Response) { + return loaded; } - props = await extractProps(result); + props = loaded; } const pageHead = headFn ? headFn(c, props) : undefined; @@ -463,21 +478,24 @@ export async function handlePageGet( const isPrefetch = c.req.header('purpose') === 'prefetch'; if (isPrefetch && hasDeferred) { const resolvedProps: Record = { ...immediateProps }; + let deferredErrors: Record | undefined; await Promise.all( - Object.entries(deferredMap).map(async ([, { resolver, keys }]) => { + Object.entries(deferredMap).map(async ([group, { resolver, keys }]) => { try { const data = await resolver(c); if (keys.length === 1) { - resolvedProps[keys[0]] = data; + resolvedProps[keys[0]] = data ?? null; } else { - const obj = data as Record; + const obj = + typeof data === 'object' && data !== null ? (data as Record) : {}; for (const key of keys) { resolvedProps[key] = obj[key]; } } - } catch { + } catch (err) { + (deferredErrors ??= {})[group] = deferredErrorMessage(err, options?.dev === true); for (const key of keys) { - resolvedProps[key] = null; + delete resolvedProps[key]; } } }), @@ -486,6 +504,7 @@ export async function handlePageGet( ...pageObj, props: resolvedProps, deferred: undefined, + ...(deferredErrors && { deferredErrors }), }); } @@ -503,7 +522,7 @@ export async function handlePageGet( c, 'application/x-ndjson', options?.dev === true, - (group, data) => JSON.stringify({ __deferred: group, data }) + '\n', + (group, data) => JSON.stringify({ __deferred: group, data: data ?? null }) + '\n', (group, error) => JSON.stringify({ __deferred: group, error }) + '\n', ); } @@ -600,8 +619,11 @@ export async function handlePageMutation( /** Re-run the GET loader and respond with the page, optionally carrying validation errors. */ async function respondWithPage(errors: Record | null): Promise { - const rawProps = getHandler ? await extractProps(await getHandler(c)) : {}; - const pageObj = buildMutationPageObj(stripDeferred(rawProps), errors); + const loaded = getHandler ? await runLoader(getHandler, c) : {}; + if (loaded instanceof Response) { + return loaded; + } + const pageObj = buildMutationPageObj(stripDeferred(loaded), errors); if (isPagesRequest(c)) { return pageJsonResponse(c, pageObj); } @@ -640,9 +662,16 @@ export async function handlePageMutation( if (status >= 300 && status < 400) { return result; } - // withValidator 400 + // Only a withValidator issues body becomes field errors; other 400s pass through if (status === 400) { - return respondWithErrors(flattenValidationErrors(await result.json())); + const body: unknown = await result + .clone() + .json() + .catch(() => null); + if (Array.isArray((body as ValidationErrorBody | null)?.issues)) { + return respondWithErrors(flattenValidationErrors(body)); + } + return result; } // 204 (void from withValidator) — fall through to re-run GET if (status !== 204) { diff --git a/packages/void/test/unit/pages-client.test.ts b/packages/void/test/unit/pages-client.test.ts index 15dd25be2..e06437ee9 100644 --- a/packages/void/test/unit/pages-client.test.ts +++ b/packages/void/test/unit/pages-client.test.ts @@ -235,6 +235,47 @@ describe('createVoidRouter', () => { }, ); + it('rejects a failed deferred group from a prefetched payload', async () => { + stubClientGlobals(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + pageJson({ + component: 'users', + props: { stats: { count: 1 } }, + deferredKeys: { stats: 'stats', usage: 'usage' }, + deferredErrors: { usage: 'Internal Server Error' }, + }), + ), + ); + const applyUpdate = vi.fn(); + const { router } = await createTestRouter({ + adapter: { + applyUpdate, + createDeferred: () => ({ loading: true, value: null, error: null }), + isLoading: (ref) => (ref as { loading: boolean }).loading, + resolveDeferred: (_ref, value) => ({ loading: false, value, error: null }), + rejectDeferred: (_ref, error) => ({ loading: false, value: null, error }), + }, + components: { home: loadComponent, users: loadComponent }, + matchRoute: () => 'users', + }); + try { + await router.prefetch('/users'); + await router.visit('/users'); + + const rendered = applyUpdate.mock.calls.at(-1)![0]; + expect(rendered.props.stats).toEqual({ loading: false, value: { count: 1 }, error: null }); + expect(rendered.props.usage).toEqual({ + loading: false, + value: null, + error: 'Internal Server Error', + }); + } finally { + router.flushAll(); + } + }); + it('serves revalidated data after a stale-while-revalidate refresh', async () => { stubClientGlobals(); let n = 0; @@ -397,6 +438,77 @@ describe('createVoidRouter', () => { expect(resolveDeferred).not.toHaveBeenCalled(); }); + it.each([ + { + name: 'routes an ungrouped deferred object to its own prop only', + deferredKeys: { stats: 'stats', comments: 'comments' }, + messages: [ + ['stats', { comments: 12, views: 100 }], + ['comments', [{ id: 1 }]], + ], + pendingAfterFirst: ['comments'], + expected: { stats: { comments: 12, views: 100 }, comments: [{ id: 1 }] }, + }, + { + name: 'splits a named group object across its props', + deferredKeys: { metrics: 'analytics', chart: 'analytics' }, + messages: [['analytics', { metrics: 1, chart: 2 }]], + pendingAfterFirst: [], + expected: { metrics: 1, chart: 2 }, + }, + { + name: 'resolves each named group prop to undefined for non-object data', + deferredKeys: { metrics: 'analytics', chart: 'analytics' }, + messages: [['analytics', null]], + pendingAfterFirst: [], + expected: { metrics: undefined, chart: undefined }, + }, + ])('$name', async ({ deferredKeys, messages, pendingAfterFirst, expected }) => { + stubClientGlobals(); + const initialPayload = { component: 'home', deferredKeys, props: {} }; + vi.mocked(document.getElementById).mockImplementation((id: string) => { + if (id === '__VOID_PAGE_DATA__') { + return { textContent: JSON.stringify(initialPayload) } as HTMLElement; + } + if (id === 'app') { + return { getAttribute: vi.fn(() => null) } as unknown as HTMLElement; + } + return null; + }); + let props: Record = {}; + const { start } = await createTestRouter({ + adapter: { + createDeferred: () => ({ loading: true, value: null, error: null }), + isLoading: (ref) => Boolean((ref as { loading?: boolean })?.loading), + resolveDeferred: (_ref, value) => ({ loading: false, value, error: null }), + rejectDeferred: (_ref, error) => ({ loading: false, value: null, error }), + onDeferredUpdate: (next) => { + props = { ...next }; + }, + }, + }); + + start(); + const resolveDeferred = (window as unknown as DeferredTestWindow).__resolveDeferred; + for (const [index, [group, data]] of messages.entries()) { + resolveDeferred(group as string, data); + if (index === 0) { + for (const key of pendingAfterFirst) { + expect(props[key]).toEqual({ loading: true, value: null, error: null }); + } + } + } + + expect(props).toEqual( + Object.fromEntries( + Object.entries(expected).map(([key, value]) => [ + key, + { loading: false, value, error: null }, + ]), + ), + ); + }); + it('runs navigation listeners for same-page hash visits', async () => { const documentStub = stubClientGlobals(); window.location.pathname = '/docs'; @@ -790,6 +902,51 @@ describe('createVoidRouter', () => { }); }); + it('splits a group object when a mutation preserves only part of the group', async () => { + stubClientGlobals(); + vi.mocked(document.getElementById).mockImplementation((id: string) => { + if (id === '__VOID_PAGE_DATA__') { + return { + textContent: JSON.stringify({ + component: 'home', + deferredKeys: { metrics: 'analytics', chart: 'analytics' }, + props: {}, + }), + } as HTMLElement; + } + if (id === 'app') { + return { getAttribute: vi.fn(() => null) } as unknown as HTMLElement; + } + return null; + }); + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve(pageJson({ component: 'home', props: { metrics: null, chart: 'new' } })), + ), + ); + const resolveDeferred = vi.fn((_ref, value) => ({ loading: false, value, error: null })); + + const { router, start } = await createTestRouter({ + adapter: { + applyUpdate: vi.fn(), + createDeferred: () => ({ loading: true, value: null, error: null }), + isLoading: (ref) => Boolean((ref as { loading?: boolean })?.loading), + rejectDeferred: (_ref, error) => ({ loading: false, value: null, error }), + resolveDeferred, + }, + }); + + start(); + await router.visit('/', { method: 'POST', data: { ok: true } }); + (window as unknown as DeferredTestWindow).__resolveDeferred('analytics', { + metrics: 1, + chart: 2, + }); + + expect(resolveDeferred).toHaveBeenCalledExactlyOnceWith(expect.anything(), 1); + }); + it('keeps the committed page deferred registry alive while a new navigation is pending', async () => { stubClientGlobals(); vi.mocked(document.getElementById).mockImplementation((id: string) => { diff --git a/packages/void/test/unit/pages-islands-rescan.test.ts b/packages/void/test/unit/pages-islands-rescan.test.ts new file mode 100644 index 000000000..36a911353 --- /dev/null +++ b/packages/void/test/unit/pages-islands-rescan.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from 'node:events'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ViteDevServer } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { watchPagesForRescan } from '../../src/pages/islands-plugin.ts'; + +const TMP = join(import.meta.dirname, '__tmp_pages_islands_rescan__'); +const PAGES_DIR = join(TMP, 'pages'); + +function writePage(relativePath: string, source: string): string { + const full = join(PAGES_DIR, relativePath); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, source); + return full; +} + +function setup(onRescan: () => void) { + const watcher = new EventEmitter(); + const server = { watcher, environments: {} } as unknown as ViteDevServer; + const { flush } = watchPagesForRescan(server, PAGES_DIR, TMP, onRescan); + return { + /** Emit a watcher event, run the debounce timer, and return the scan promise. */ + emitAdd(path: string): Promise { + watcher.emit('add', path); + vi.runAllTimers(); + return flush(); + }, + }; +} + +describe('watchPagesForRescan', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + rmSync(TMP, { recursive: true, force: true }); + }); + + it('keeps the last scan when a rescan fails and recovers on the next change', async () => { + writePage('index.md', '# Home\n'); + const onRescan = vi.fn(); + const { emitAdd } = setup(onRescan); + + await expect( + emitAdd(writePage('about.md', '---\nlayout: docs\n---\n# About\n')), + ).resolves.toBeUndefined(); + expect(onRescan).not.toHaveBeenCalled(); + + await emitAdd(writePage('_layouts/docs.vue', '\n')); + expect(onRescan).toHaveBeenCalledTimes(1); + }); + + it('surfaces an error thrown after a good scan', async () => { + writePage('index.md', '# Home\n'); + const { emitAdd } = setup(() => { + throw new Error('rescan failed'); + }); + + await expect(emitAdd(writePage('about.md', '# About\n'))).rejects.toThrow('rescan failed'); + }); +}); diff --git a/packages/void/test/unit/pages-protocol.test.ts b/packages/void/test/unit/pages-protocol.test.ts index 7295581dc..0c3d5ae78 100644 --- a/packages/void/test/unit/pages-protocol.test.ts +++ b/packages/void/test/unit/pages-protocol.test.ts @@ -423,6 +423,79 @@ describe('handlePageGet', () => { // deferredKeys is preserved so clients can wrap resolved values expect(body.deferredKeys).toEqual({ usage: 'usage' }); }); + + it.each([ + { dev: false, message: 'Internal Server Error' }, + { dev: true, message: 'db down' }, + ])( + 'prefetch request — sends a failed deferred group as an inline error (dev=$dev)', + async ({ dev, message }) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const c = await createTestContext('GET', '/dashboard', { + headers: { 'x-voidpages': 'true', purpose: 'prefetch' }, + }); + const error = new Error('db down'); + + try { + const response = await handlePageGet( + () => ({ + stats: { count: 1 }, + usage: defer(async () => { + throw error; + }), + ok: defer(async () => 1), + }), + undefined, + c, + 'dashboard', + mockRenderFn, + mockGetAssetTags, + undefined, + { dev }, + ); + + const body = await json }>( + response, + ); + expect(body.deferredErrors).toEqual({ usage: message }); + expect(body.props).toEqual({ stats: { count: 1 }, ok: 1 }); + expect(body.deferredKeys).toEqual({ usage: 'usage', ok: 'ok' }); + expect(consoleError).toHaveBeenCalledExactlyOnceWith(error); + } finally { + consoleError.mockRestore(); + } + }, + ); + + it('prefetch request — resolves empty deferred results like the stream does', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const c = await createTestContext('GET', '/dashboard', { + headers: { 'x-voidpages': 'true', purpose: 'prefetch' }, + }); + + try { + const response = await handlePageGet( + () => ({ + post: defer(async () => undefined), + a: defer('g', async () => null), + b: defer('g', async () => null), + }), + undefined, + c, + 'dashboard', + mockRenderFn, + mockGetAssetTags, + ); + + const body = await json }>(response); + expect(body.props).toEqual({ post: null }); + expect(body.deferredErrors).toBeUndefined(); + expect(body.deferredKeys).toEqual({ post: 'post', a: 'g', b: 'g' }); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }); }); describe('handlePageMutation', () => { @@ -642,6 +715,87 @@ describe('handlePageMutation', () => { expect(body.errors).toEqual({ name: 'required' }); }); + it.each([ + { + name: 'JSON without withValidator issues', + response: () => Response.json({ error: 'Slug taken' }, { status: 400 }), + body: '{"error":"Slug taken"}', + }, + { + name: 'text', + response: () => new Response('Missing title', { status: 400 }), + body: 'Missing title', + }, + ])('Response 400 with $name passes through unchanged', async ({ response, body }) => { + const c = await createTestContext('POST', '/users', { + headers: { 'x-voidpages': 'true' }, + }); + const getHandler = vi.fn(() => ({ users: [] })); + + const result = await handlePageMutation( + () => response(), + getHandler, + undefined, + c, + 'users', + mockRenderFn, + mockGetAssetTags, + ); + + expect(result.status).toBe(400); + expect(await result.text()).toBe(body); + expect(getHandler).not.toHaveBeenCalled(); + }); + + it.each<{ + name: string; + headers: Record; + action: () => undefined; + loader: (c: Context) => Response | Promise; + status: number; + }>([ + { + name: 'void action passes through a loader redirect', + headers: { 'x-voidpages': 'true' }, + action: () => undefined, + loader: (c: Context) => c.redirect('/posts'), + status: 302, + }, + { + name: 'void action passes through a loader 404', + headers: {}, + action: () => undefined, + loader: (c: Context) => c.notFound(), + status: 404, + }, + { + name: 'ValidationError passes through a loader redirect', + headers: { 'x-voidpages': 'true' }, + action: () => { + throw new ValidationError({ title: 'required' }); + }, + loader: (c: Context) => c.redirect('/posts'), + status: 302, + }, + ])('$name', async ({ headers, action, loader, status }) => { + const c = await createTestContext('POST', '/posts/1', { headers }); + const render = vi.fn(async () => ''); + + const response = await handlePageMutation( + action, + loader, + undefined, + c, + 'post', + render, + mockGetAssetTags, + ); + + expect(response.status).toBe(status); + expect(response.headers.get('Location')).toBe(status === 302 ? '/posts' : null); + expect(render).not.toHaveBeenCalled(); + }); + it('Response 204 (void from withValidator) — re-runs GET', async () => { const c = await createTestContext('POST', '/users', { headers: { 'x-voidpages': 'true' }, @@ -857,6 +1011,24 @@ describe('handlePageGet with deferred props', () => { expect(deferredLine.data).toEqual({ requests: 100 }); }); + it('SPA — streams null for a deferred resolver that returns undefined', async () => { + const c = await createTestContext('GET', '/post', { + headers: { 'x-voidpages': 'true' }, + }); + + const response = await handlePageGet( + () => ({ title: 'x', post: defer(() => Promise.resolve(undefined)) }), + undefined, + c, + 'post', + mockRenderFn, + mockGetAssetTags, + ); + + const lines = (await response.text()).trim().split('\n'); + expect(JSON.parse(lines[1])).toEqual({ __deferred: 'post', data: null }); + }); + it('SPA — dev streams the raw error for a failed deferred resolver', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const c = await createTestContext('GET', '/dashboard', { diff --git a/packages/vue/src/plugin-pages.ts b/packages/vue/src/plugin-pages.ts index a1629774c..5897a94f6 100644 --- a/packages/vue/src/plugin-pages.ts +++ b/packages/vue/src/plugin-pages.ts @@ -208,7 +208,16 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Rea if (event === 'change' && !normalizedPath.endsWith('.md')) { return; } - await runRescan(); + try { + await runRescan(); + } catch (error) { + // Keep the last good scan; the next change rescans. + server.config.logger.error(error instanceof Error ? error.message : String(error), { + error: error instanceof Error ? error : undefined, + timestamp: true, + }); + return; + } invalidatePageModules(server); }; diff --git a/packages/vue/test/unit/plugin-pages.test.ts b/packages/vue/test/unit/plugin-pages.test.ts index 86615c2aa..e67656490 100644 --- a/packages/vue/test/unit/plugin-pages.test.ts +++ b/packages/vue/test/unit/plugin-pages.test.ts @@ -1,6 +1,6 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { pagesPlugin } from '../../src/plugin-pages.ts'; const TMP = join(import.meta.dirname, '__tmp_pages_plugin__'); @@ -36,7 +36,7 @@ function writePage(relativePath: string, source: string): void { writeFileSync(full, source); } -function setupWatcher(plugin: PagesPluginHooks) { +function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateModule = vi.fn()) { const handlers = new Map Promise | void>>(); plugin.configureServer?.({ watcher: { @@ -46,7 +46,10 @@ function setupWatcher(plugin: PagesPluginHooks) { handlers.set(event, eventHandlers); }, }, - environments: {}, + environments: { + client: { moduleGraph: { getModuleById: (id: string) => ({ id }), invalidateModule } }, + }, + config: { logger: { error: logError } }, }); return handlers; } @@ -79,4 +82,43 @@ describe('pagesPlugin dev HMR', () => { expect(renderer).toContain('"test": () => import'); }); + + it('logs a page scan error from the watcher and keeps the last scan', 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.'); + } + getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.buildStart).call({}); + const logError = vi.fn(); + const invalidateModule = vi.fn(); + const handlers = setupWatcher(plugin, logError, invalidateModule); + const emitAdd = async (relativePath: string) => { + for (const handler of handlers.get('add') ?? []) { + await expect(handler(join(PAGES_DIR, relativePath))).resolves.toBeUndefined(); + } + }; + + writePage('about.md', '---\nlayout: docs\n---\n# About\n'); + await emitAdd('about.md'); + + expect(logError).toHaveBeenCalledTimes(1); + expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'"); + expect(invalidateModule).not.toHaveBeenCalled(); + const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + expect(renderer).toContain('"index": () => import'); + expect(renderer).not.toContain('"about"'); + + writePage('_layouts/docs.vue', '\n'); + await emitAdd('_layouts/docs.vue'); + + expect(logError).toHaveBeenCalledTimes(1); + expect(invalidateModule).toHaveBeenCalled(); + const recovered = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + expect(recovered).toContain('"about"'); + }); });