Skip to content

Commit d8618c8

Browse files
Brooooooklynclaude
andauthored
fix(pages): deferred props, action responses and dev rescan errors (#517)
Fixes six bugs in Pages SSR: deferred props, action responses, and dev rescans. | Bug | Before | Now | |---|---|---| | A page scan error during a dev rescan (e.g. `layout: docs` before `_layouts/docs.*` exists) | unhandled rejection, `void dev` exits | the error is logged and the last good scan is kept; the next change rescans | | A deferred value whose object has a key named like another deferred prop | the value lands in that other prop, and its own prop never resolves | a group resolves only its own keys | | A deferred resolver that throws, in prefetch or prerender | silent `null`, baked into `_void/pages/*.json` | sent as `deferredErrors`, and the client rejects that prop as it does when streaming | | A deferred resolver that returns `undefined`, on client navigation | stays loading forever | resolves to `null`, as on a full page load | | A loader that returns a redirect or 404 after an action | page renders with `{}` props | the loader's Response is returned, as on a GET | | A 400 from an action that is not from `withValidator` | a text body becomes a 500; a JSON body is swallowed | passed through unchanged; only an `issues` body becomes field errors | ## How ``` protocol.ts runLoader(): one rule for GET and post-action loader runs prefetch: deferredErrors per failed group; undefined → null NDJSON: data ?? null 400: clone().json() → issues[] ? field errors : pass through navigation-engine resolveGroup: one key → raw value; several keys → value[key] setup(resolved): a key whose group is in deferredErrors is rejected retainOnly keeps full groups, so a preserved part of a group still splits correctly islands-plugin + rescan errors are handled; the adapter pages plugin logs them once 4 adapter plugins ``` The deferred rule is the one the server's prefetch path and the docs ("Grouped Deferred Props") already use. ## Tests - **New tests:** 17, in `pages-client`, `pages-protocol`, the new `pages-islands-rescan`, and the react/vue/svelte/solid `plugin-pages` tests. All 17 fail on `main`. - **Unit tests:** the pages, prerender and island files in void pass (326 tests), as do all the adapters' unit tests. - **Browser tests:** React 44/44 and Vue 30/30. - **Checks:** typecheck for void and the 4 adapters, lint and format are clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 78afe55 commit d8618c8

14 files changed

Lines changed: 699 additions & 76 deletions

File tree

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,16 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu
151151
if (event === 'change' && !normalizedPath.endsWith('.md')) {
152152
return;
153153
}
154-
await runRescan();
154+
try {
155+
await runRescan();
156+
} catch (error) {
157+
// Keep the last good scan; the next change rescans.
158+
server.config.logger.error(error instanceof Error ? error.message : String(error), {
159+
error: error instanceof Error ? error : undefined,
160+
timestamp: true,
161+
});
162+
return;
163+
}
155164
invalidatePageModules(server);
156165
};
157166

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
22
import { join } from 'node:path';
3-
import { afterEach, describe, expect, it } from 'vite-plus/test';
3+
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
44
import { pagesPlugin } from '../../src/plugin-pages.ts';
55

66
const TMP = join(import.meta.dirname, '__tmp_pages_plugin__');
@@ -45,7 +45,7 @@ function writePage(relativePath: string, source: string): void {
4545
writeFileSync(full, source);
4646
}
4747

48-
function setupWatcher(plugin: PagesPluginHooks) {
48+
function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateModule = vi.fn()) {
4949
const handlers = new Map<string, Array<(path: string) => Promise<void> | void>>();
5050
plugin.configureServer?.({
5151
watcher: {
@@ -55,7 +55,10 @@ function setupWatcher(plugin: PagesPluginHooks) {
5555
handlers.set(event, eventHandlers);
5656
},
5757
},
58-
environments: {},
58+
environments: {
59+
client: { moduleGraph: { getModuleById: (id: string) => ({ id }), invalidateModule } },
60+
},
61+
config: { logger: { error: logError } },
5962
});
6063
return handlers;
6164
}
@@ -104,4 +107,41 @@ describe('pagesPlugin dev config', () => {
104107

105108
expect(renderer).toContain('"test": () => import');
106109
});
110+
111+
it('logs a page scan error from the watcher and keeps the last scan', async () => {
112+
writePage('index.tsx', 'export default function Index() { return <h1>Home</h1>; }\n');
113+
114+
const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks;
115+
getHookHandler(plugin.config)({}, { command: 'serve' });
116+
await getHookHandler(plugin.buildStart).call({});
117+
const logError = vi.fn();
118+
const invalidateModule = vi.fn();
119+
const handlers = setupWatcher(plugin, logError, invalidateModule);
120+
const emitAdd = async (relativePath: string) => {
121+
for (const handler of handlers.get('add') ?? []) {
122+
await expect(handler(join(PAGES_DIR, relativePath))).resolves.toBeUndefined();
123+
}
124+
};
125+
126+
writePage('about.md', '---\nlayout: docs\n---\n# About\n');
127+
await emitAdd('about.md');
128+
129+
expect(logError).toHaveBeenCalledTimes(1);
130+
expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'");
131+
expect(invalidateModule).not.toHaveBeenCalled();
132+
const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
133+
expect(renderer).toContain('"index": () => import');
134+
expect(renderer).not.toContain('"about"');
135+
136+
writePage(
137+
'_layouts/docs.tsx',
138+
'export default function Docs({ children }) { return <main>{children}</main>; }\n',
139+
);
140+
await emitAdd('_layouts/docs.tsx');
141+
142+
expect(logError).toHaveBeenCalledTimes(1);
143+
expect(invalidateModule).toHaveBeenCalled();
144+
const recovered = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
145+
expect(recovered).toContain('"about"');
146+
});
107147
});

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,16 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu
128128
if (event === 'change' && !normalizedPath.endsWith('.md')) {
129129
return;
130130
}
131-
await runRescan();
131+
try {
132+
await runRescan();
133+
} catch (error) {
134+
// Keep the last good scan; the next change rescans.
135+
server.config.logger.error(error instanceof Error ? error.message : String(error), {
136+
error: error instanceof Error ? error : undefined,
137+
timestamp: true,
138+
});
139+
return;
140+
}
132141
invalidatePageModules(server);
133142
};
134143

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
22
import { join } from 'node:path';
3-
import { afterEach, describe, expect, it } from 'vite-plus/test';
3+
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
44
import { pagesPlugin } from '../../src/plugin-pages.ts';
55

66
const TMP = join(import.meta.dirname, '__tmp_pages_plugin__');
@@ -36,7 +36,7 @@ function writePage(relativePath: string, source: string): void {
3636
writeFileSync(full, source);
3737
}
3838

39-
function setupWatcher(plugin: PagesPluginHooks) {
39+
function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateModule = vi.fn()) {
4040
const handlers = new Map<string, Array<(path: string) => Promise<void> | void>>();
4141
plugin.configureServer?.({
4242
watcher: {
@@ -46,7 +46,10 @@ function setupWatcher(plugin: PagesPluginHooks) {
4646
handlers.set(event, eventHandlers);
4747
},
4848
},
49-
environments: {},
49+
environments: {
50+
client: { moduleGraph: { getModuleById: (id: string) => ({ id }), invalidateModule } },
51+
},
52+
config: { logger: { error: logError } },
5053
});
5154
return handlers;
5255
}
@@ -74,4 +77,41 @@ describe('pagesPlugin dev HMR', () => {
7477

7578
expect(renderer).toContain('"test": () => import');
7679
});
80+
81+
it('logs a page scan error from the watcher and keeps the last scan', async () => {
82+
writePage('index.tsx', 'export default function Index() { return <h1>Home</h1>; }\n');
83+
84+
const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks;
85+
getHookHandler(plugin.config)({}, { command: 'serve' });
86+
await getHookHandler(plugin.buildStart).call({});
87+
const logError = vi.fn();
88+
const invalidateModule = vi.fn();
89+
const handlers = setupWatcher(plugin, logError, invalidateModule);
90+
const emitAdd = async (relativePath: string) => {
91+
for (const handler of handlers.get('add') ?? []) {
92+
await expect(handler(join(PAGES_DIR, relativePath))).resolves.toBeUndefined();
93+
}
94+
};
95+
96+
writePage('about.md', '---\nlayout: docs\n---\n# About\n');
97+
await emitAdd('about.md');
98+
99+
expect(logError).toHaveBeenCalledTimes(1);
100+
expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'");
101+
expect(invalidateModule).not.toHaveBeenCalled();
102+
const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
103+
expect(renderer).toContain('"index": () => import');
104+
expect(renderer).not.toContain('"about"');
105+
106+
writePage(
107+
'_layouts/docs.tsx',
108+
'export default function Docs(props) { return <main>{props.children}</main>; }\n',
109+
);
110+
await emitAdd('_layouts/docs.tsx');
111+
112+
expect(logError).toHaveBeenCalledTimes(1);
113+
expect(invalidateModule).toHaveBeenCalled();
114+
const recovered = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
115+
expect(recovered).toContain('"about"');
116+
});
77117
});

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,16 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu
135135
if (event === 'change' && !normalizedPath.endsWith('.md')) {
136136
return;
137137
}
138-
await runRescan();
138+
try {
139+
await runRescan();
140+
} catch (error) {
141+
// Keep the last good scan; the next change rescans.
142+
server.config.logger.error(error instanceof Error ? error.message : String(error), {
143+
error: error instanceof Error ? error : undefined,
144+
timestamp: true,
145+
});
146+
return;
147+
}
139148
invalidatePageModules(server);
140149
};
141150

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
22
import { join } from 'node:path';
3-
import { afterEach, describe, expect, it } from 'vite-plus/test';
3+
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
44
import { pagesPlugin } from '../../src/plugin-pages.ts';
55

66
const TMP = join(import.meta.dirname, '__tmp_pages_plugin__');
@@ -36,7 +36,7 @@ function writePage(relativePath: string, source: string): void {
3636
writeFileSync(full, source);
3737
}
3838

39-
function setupWatcher(plugin: PagesPluginHooks) {
39+
function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateModule = vi.fn()) {
4040
const handlers = new Map<string, Array<(path: string) => Promise<void> | void>>();
4141
plugin.configureServer?.({
4242
watcher: {
@@ -46,7 +46,10 @@ function setupWatcher(plugin: PagesPluginHooks) {
4646
handlers.set(event, eventHandlers);
4747
},
4848
},
49-
environments: {},
49+
environments: {
50+
client: { moduleGraph: { getModuleById: (id: string) => ({ id }), invalidateModule } },
51+
},
52+
config: { logger: { error: logError } },
5053
});
5154
return handlers;
5255
}
@@ -75,6 +78,43 @@ describe('pagesPlugin dev HMR', () => {
7578
expect(renderer).toContain('"test": () => import');
7679
});
7780

81+
it('logs a page scan error from the watcher and keeps the last scan', async () => {
82+
writePage('index.svelte', '<h1>Home</h1>\n');
83+
84+
const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks;
85+
getHookHandler(plugin.config)({}, { command: 'serve' });
86+
await getHookHandler(plugin.buildStart).call({});
87+
const logError = vi.fn();
88+
const invalidateModule = vi.fn();
89+
const handlers = setupWatcher(plugin, logError, invalidateModule);
90+
const emitAdd = async (relativePath: string) => {
91+
for (const handler of handlers.get('add') ?? []) {
92+
await expect(handler(join(PAGES_DIR, relativePath))).resolves.toBeUndefined();
93+
}
94+
};
95+
96+
writePage('about.md', '---\nlayout: docs\n---\n# About\n');
97+
await emitAdd('about.md');
98+
99+
expect(logError).toHaveBeenCalledTimes(1);
100+
expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'");
101+
expect(invalidateModule).not.toHaveBeenCalled();
102+
const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
103+
expect(renderer).toContain('"index": () => import');
104+
expect(renderer).not.toContain('"about"');
105+
106+
writePage(
107+
'_layouts/docs.svelte',
108+
'<script>let { children } = $props();</script>\n<main>{@render children()}</main>\n',
109+
);
110+
await emitAdd('_layouts/docs.svelte');
111+
112+
expect(logError).toHaveBeenCalledTimes(1);
113+
expect(invalidateModule).toHaveBeenCalled();
114+
const recovered = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
115+
expect(recovered).toContain('"about"');
116+
});
117+
78118
it('excludes @void/svelte from the void worker optimizer in dev', () => {
79119
writePage('index.svelte', '<h1>Home</h1>\n');
80120

‎packages/void/src/pages/islands-plugin.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -327,16 +327,25 @@ export function watchPagesForRescan(
327327
}
328328
debounceTimer = setTimeout(() => {
329329
debounceTimer = null;
330-
pending = scanPages(root, { pagesDir }).then((pageScan) => {
331-
onRescan(pageScan);
332-
for (const env of Object.values(server.environments)) {
333-
const mod = env.moduleGraph.getModuleById('\0' + ISLANDS_RENDERER_ID);
334-
if (mod) {
335-
env.moduleGraph.invalidateModule(mod);
336-
}
337-
}
338-
pending = null;
339-
});
330+
pending = scanPages(root, { pagesDir })
331+
.then(
332+
(pageScan) => {
333+
onRescan(pageScan);
334+
for (const env of Object.values(server.environments)) {
335+
const mod = env.moduleGraph.getModuleById('\0' + ISLANDS_RENDERER_ID);
336+
if (mod) {
337+
env.moduleGraph.invalidateModule(mod);
338+
}
339+
}
340+
},
341+
() => {
342+
// The adapter pages plugin runs the same scan on the same events and
343+
// logs its error. Keep the last good scan until the next change.
344+
},
345+
)
346+
.finally(() => {
347+
pending = null;
348+
});
340349
}, 50);
341350
};
342351

‎packages/void/src/pages/navigation-engine.ts‎

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,14 @@ export async function createPageNavigationEngine(
293293
}
294294

295295
for (const [key, group] of Object.entries(pageData.deferredKeys) as Array<[string, string]>) {
296-
const ref = adapter.createDeferred();
297-
const value = resolved ? adapter.resolveDeferred(ref, pageData.props[key]) : ref;
296+
let value = adapter.createDeferred();
297+
if (resolved) {
298+
const error = pageData.deferredErrors?.[group];
299+
value =
300+
error === undefined
301+
? adapter.resolveDeferred(value, pageData.props[key])
302+
: adapter.rejectDeferred(value, error);
303+
}
298304
this.keyRefs[key] = value;
299305
this.props[key] = value;
300306
(this.groups[group] ??= []).push(key);
@@ -340,20 +346,7 @@ export async function createPageNavigationEngine(
340346
}
341347

342348
if (data !== undefined) {
343-
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
344-
let resolvedAny = false;
345-
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
346-
if (this.keyRefs[key]) {
347-
this.resolveKey(key, value);
348-
resolvedAny = true;
349-
}
350-
}
351-
if (!resolvedAny) {
352-
this.resolveGroup(group, data);
353-
}
354-
} else {
355-
this.resolveGroup(group, data);
356-
}
349+
this.resolveGroup(group, data);
357350
} else if (error !== undefined) {
358351
this.rejectGroup(group, typeof error === 'string' ? error : String(error));
359352
}
@@ -388,7 +381,6 @@ export async function createPageNavigationEngine(
388381

389382
private retainOnly(keys: Set<string>, props: Record<string, unknown>): void {
390383
const keyRefs: Record<string, unknown> = {};
391-
const groups: Record<string, Array<string>> = {};
392384
for (const [key, ref] of Object.entries(this.keyRefs)) {
393385
if (keys.has(key)) {
394386
keyRefs[key] = ref;
@@ -397,22 +389,24 @@ export async function createPageNavigationEngine(
397389
adapter.rejectDeferred(ref, 'Navigation disposed before deferred props resolved');
398390
}
399391
}
400-
for (const [group, groupKeys] of Object.entries(this.groups)) {
401-
const preservedGroupKeys = groupKeys.filter((key) => keys.has(key));
402-
if (preservedGroupKeys.length > 0) {
403-
groups[group] = preservedGroupKeys;
404-
}
405-
}
392+
// Groups keep every key so a group resolves with its original shape.
393+
// Keys dropped from keyRefs are skipped when the group settles.
406394
this.props = props;
407395
this.keyRefs = keyRefs;
408-
this.groups = groups;
409396
adapter.onDeferredUpdate?.(this.props);
410397
}
411398

399+
/** A one-key group takes the raw value; a larger group takes each key from the object. */
412400
private resolveGroup(group: string, data: unknown): void {
413401
const keys = this.groups[group] ?? (this.keyRefs[group] ? [group] : []);
402+
if (keys.length === 1) {
403+
this.resolveKey(keys[0]!, data);
404+
return;
405+
}
406+
const values =
407+
typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : {};
414408
for (const key of keys) {
415-
this.resolveKey(key, data);
409+
this.resolveKey(key, values[key]);
416410
}
417411
}
418412

0 commit comments

Comments
 (0)