From 0ced6457911f591e5a12c7b94a5b561a904e54a4 Mon Sep 17 00:00:00 2001 From: Sasha Koss Date: Wed, 11 Nov 2026 17:22:37 +0800 Subject: [PATCH 1/2] fix: add test cases for #784 --- .../void/test/integration/page-errors.test.ts | 55 ++++++++++ .../void/test/unit/page-boundaries.test.ts | 100 ++++++++++++++++++ .../void/test/unit/routing-runtime.test.ts | 53 ++++++++++ 3 files changed, 208 insertions(+) diff --git a/packages/void/test/integration/page-errors.test.ts b/packages/void/test/integration/page-errors.test.ts index 5e93e8403..b59d413c4 100644 --- a/packages/void/test/integration/page-errors.test.ts +++ b/packages/void/test/integration/page-errors.test.ts @@ -82,6 +82,21 @@ describe.each(frameworks)('Pages errors with %s', (framework) => { `import { defineMiddleware } from 'void/handler'; export default defineMiddleware(async (c,next)=>{c.set('shared',{viewer:'alice',...(c.req.header('X-Test-No-Root') ? { noRoot: true } : {})}); c.header('X-Security','retained'); const guard=c.req.header('X-Test-Guard'); if(guard==='not-found') return c.notFound(); if(guard==='explicit') return c.text('Hidden by guard',404); await next(); if(guard==='after-not-found') c.res=await c.notFound();});`, ); write('pages/style.css', 'nav { color: rgb(20, 80, 100); }'); + write( + 'middleware/status.ts', + `import { defineMiddleware } from 'void/handler'; +import { HTTPException } from 'hono/http-exception'; +export default defineMiddleware(async (c, next) => { + const status = c.req.header('X-Test-Error-Status'); + if (status === '403' || status === '503') { + c.header('Retry-After', '120'); + throw new HTTPException(status === '403' ? 403 : 503, { + message: status === '403' ? 'Your account is banned' : 'Down for maintenance', + }); + } + await next(); +});`, + ); const nav = ''; let rootLayout = component(nav, '', true); @@ -295,6 +310,46 @@ describe.each(frameworks)('Pages errors with %s', (framework) => { expect(await response.text()).toContain('Missing island page'); }); + it.each( + ([403, 503] as const).flatMap((status) => + (['HTML', 'Pages JSON'] as const).map((format) => ({ status, format })), + ), + )( + 'renders middleware errors through the root page boundary with HTTP $status ($format)', + async ({ status, format }) => { + const message = status === 403 ? 'Your account is banned' : 'Down for maintenance'; + // This route throws a different error if its loader is reached. + const response = await fetch(base + '/items/unexpected', { + headers: { + 'X-Test-Error-Status': String(status), + ...(format === 'Pages JSON' + ? { 'X-VoidPages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION } + : { Accept: 'text/html' }), + }, + }); + const body = await response.text(); + expect(response.status).toBe(status); + expect(body).not.toContain('SECRET_'); + if (format === 'Pages JSON') { + expect(response.headers.get('X-VoidPages')).toBe('true'); + expect(deserializePageData(body)).toMatchObject({ + props: { error: { status, message } }, + shared: { viewer: 'alice' }, + url: '/items/unexpected', + }); + } else { + expect(response.headers.get('Content-Type')).toContain('text/html'); + expect(body).toContain('Error page'); + expect(body).toContain(message); + expect(body).toContain('data-layout="root"'); + expect(body).toContain('stylesheet'); + } + expect(response.headers.get('Retry-After')).toBe('120'); + expect(response.headers.get('X-Security')).toBe('retained'); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + }, + ); + it('returns status-correct documents and marked error payloads without exposing secrets', async () => { for (const [path, status] of [ ['/unknown', 404], diff --git a/packages/void/test/unit/page-boundaries.test.ts b/packages/void/test/unit/page-boundaries.test.ts index 3609bad53..28b549767 100644 --- a/packages/void/test/unit/page-boundaries.test.ts +++ b/packages/void/test/unit/page-boundaries.test.ts @@ -167,6 +167,106 @@ describe('boundary render plans', () => { }); describe('Pages loader boundary protocol', () => { + describe.each(['HTML', 'Pages JSON'] as const)( + 'HTTP exception recovery through page boundaries (%s)', + (format) => { + it.each([ + [401, 'Please sign in'], + [403, 'Your account is banned'], + [410, 'This item was deleted'], + [429, 'Too many requests'], + [503, 'Down for maintenance'], + ] as const)('renders HTTP %s through the nearest error boundary', async (status, message) => { + const app = new Hono(); + app.onError(createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }).onError); + let loaderCalls = 0; + app.get( + '/items/1', + createPageGetHandler({ + componentId: 'items/item', + load: async () => ({ + loader: defineHandler((c) => { + loaderCalls++; + c.header('Retry-After', '120'); + throw new HTTPException(status, { message }); + }), + }), + render: async (page) => + `
${JSON.stringify(page.props)}
`, + assets: async () => ({ css: '', body: '', preloads: '' }), + headConfig: null, + renderOptions: { + boundaries: { notFound: 'not-found-plan', errors: ['items-error', 'root-error'] }, + }, + }), + ); + const response = await app.request('https://app.test/items/1', { + headers: + format === 'Pages JSON' + ? { 'X-VoidPages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION } + : { Accept: 'text/html' }, + }); + const body = await response.text(); + expect(loaderCalls).toBe(1); + expect(response.status).toBe(status); + if (format === 'Pages JSON') { + expect(response.headers.get('X-VoidPages')).toBe('true'); + expect(deserializePageData(body)).toMatchObject({ + component: 'items-error', + props: { error: { status, message } }, + url: '/items/1', + }); + } else { + expect(response.headers.get('Content-Type')).toContain('text/html'); + expect(body).toContain('data-component="items-error"'); + expect(body).toContain(JSON.stringify({ error: { status, message } })); + } + expect(response.headers.get('Retry-After')).toBe('120'); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + }); + + it.each([302, 503] as const)( + 'passes through HTTP %s with an explicit response', + async (status) => { + const app = new Hono(); + app.onError(createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }).onError); + app.get( + '/', + createPageGetHandler({ + componentId: 'index', + load: async () => ({ + loader: () => { + throw new HTTPException(status, { + res: new Response('Explicit response', { + status, + headers: status === 302 ? { Location: '/login' } : { 'Retry-After': '120' }, + }), + }); + }, + }), + render: async () => '
Error boundary
', + assets: async () => ({ css: '', body: '', preloads: '' }), + headConfig: null, + renderOptions: { boundaries: { errors: ['root-error'] } }, + }), + ); + const response = await app.request('https://app.test/', { + headers: + format === 'Pages JSON' + ? { 'X-VoidPages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION } + : { Accept: 'text/html' }, + }); + expect(response.status).toBe(status); + expect(response.headers.has('X-VoidPages')).toBe(false); + expect(response.headers.get(status === 302 ? 'Location' : 'Retry-After')).toBe( + status === 302 ? '/login' : '120', + ); + expect(await response.text()).toBe('Explicit response'); + }, + ); + }, + ); + it.each(['returned', 'thrown', 'empty-message', 'unexpected'])( 'renders %s failures as marked, uncached Pages data', async (kind) => { diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 9c3c2c169..37be56342 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -1702,6 +1702,59 @@ describe('page loaders and actions composed with defineHandler middleware', () = expect(await html.text()).toContain('{"notes":["a"]}'); }); + describe.each(['HTML', 'Pages JSON'] as const)( + 'Application-defined page status codes (%s)', + (format) => { + it.each( + ([403, 503] as const).flatMap((status) => + (['loader', 'app middleware', 'handler middleware'] as const).map((source) => ({ + status, + source, + })), + ), + )('renders the page with HTTP $status set by $source', async ({ status, source }) => { + const message = status === 403 ? 'Your account is banned' : 'Down for maintenance'; + const setStatus = defineMiddleware(async (c, next) => { + c.status(status); + c.header('Retry-After', '120'); + await next(); + }); + const loader = vi.fn((c: CloudContext) => { + if (source === 'loader') { + c.status(status); + c.header('Retry-After', '120'); + } + return { message }; + }); + const app = createPageApp( + { + loader: + source === 'handler middleware' + ? defineHandler(setStatus, loader) + : defineHandler(loader), + }, + source === 'app middleware' ? setStatus : undefined, + ); + const response = await get(app, format === 'HTML' ? {} : undefined); + const body = await response.text(); + + expect(loader).toHaveBeenCalledTimes(1); + expect(response.headers.get('Retry-After')).toBe('120'); + if (format === 'Pages JSON') { + expect(response.headers.get('X-VoidPages')).toBe('true'); + expect(decodePageData(JSON.parse(body))).toMatchObject({ + component: 'page', + props: { message }, + }); + } else { + expect(response.headers.get('Content-Type')).toContain('text/html'); + expect(body).toContain(message); + } + expect(response.status).toBe(status); + }); + }, + ); + it('re-renders the page after an action with middleware, or shows its field errors', async () => { const action = (message?: string) => defineHandler( From e73d078fdc921c0a26530b7904bf0ccf6948702e Mon Sep 17 00:00:00 2001 From: Sasha Koss Date: Wed, 11 Nov 2026 18:27:46 +0800 Subject: [PATCH 2/2] fix: preserve app-defined HTTP statuses in pages responses --- docs/guide/pages-routing/loaders.md | 17 +++++ packages/void/src/pages/protocol.ts | 10 +++ .../void/test/integration/page-errors.test.ts | 43 ++++++++++++ .../void/test/unit/routing-runtime.test.ts | 67 +++++++++++++++++-- 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/docs/guide/pages-routing/loaders.md b/docs/guide/pages-routing/loaders.md index a3eb14044..05fca4214 100644 --- a/docs/guide/pages-routing/loaders.md +++ b/docs/guide/pages-routing/loaders.md @@ -29,6 +29,23 @@ export const loader = defineHandler(async (c) => { Void preserves `Date`, `Map`, `Set`, `bigint`, `RegExp`, `URL`, `undefined`, and repeated or cyclic references in page props and shared data. These values keep their types during server rendering, hydration, navigation, and deferred loading. Return data directly from your loader; explicitly returning `Response.json()` uses ordinary JSON conversion instead. Functions and custom class instances cannot be sent as page data. +## Response Status + +Use `c.status()` to render the current page with an HTTP error status, such as `403` or `503`. Return the page's props as usual: + +```ts +// pages/maintenance.server.ts +import { defineHandler } from 'void/handler'; + +export const loader = defineHandler((c) => { + c.status(503); + c.header('Retry-After', '120'); + return { message: 'Down for maintenance' }; +}); +``` + +The status and headers apply to both the HTML document and client-side navigation responses. Client visits still display the page with its props and layouts. Middleware can also set the status before `await next()`. For pages with deferred props, set the status in the loader before streaming starts. + ## Using the Data in Page Components The page component receives the loader's result as props. Export the inferred type from the server file so the component stays in sync: diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 912df0681..aaf153536 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -289,6 +289,11 @@ async function renderPageHtml( function pageJsonResponse(c: CloudContext, pageObj: PageObject): Response { return new Response(serializePageData(pageObj), { + // Hono's lazy c.res getter ignores the private status set by c.status(). + // Use its response factory to read that status without changing our header merging. + // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L403-L407 + // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L652-L659 + status: c.newResponse(null).status, headers: pageResponseHeaders(c, { 'X-VoidPages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION, @@ -581,6 +586,11 @@ function renderedResponse( assetTags?: AssetTags, ): Response { return new Response(result, { + // Hono's lazy c.res getter ignores the private status set by c.status(). + // Use its response factory to read that status without changing our header merging. + // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L403-L407 + // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L652-L659 + status: c.newResponse(null).status, headers: adjustDevCssCsp( pageResponseHeaders(c, { 'content-type': contentType, diff --git a/packages/void/test/integration/page-errors.test.ts b/packages/void/test/integration/page-errors.test.ts index b59d413c4..ebb549838 100644 --- a/packages/void/test/integration/page-errors.test.ts +++ b/packages/void/test/integration/page-errors.test.ts @@ -134,6 +134,16 @@ export default defineMiddleware(async (c, next) => { write(`pages/_error.${extension}`, component('

Error page

{{error.message}}

')); } write(`pages/items/[id].${extension}`, component('

Item

')); + write(`pages/status/[code].${extension}`, component('

Status page

')); + write( + 'pages/status/[code].server.ts', + `import { defineHandler } from 'void/handler'; +export const loader = defineHandler(c => { + c.status(c.req.param('code') === '403' ? 403 : 503); + c.header('Retry-After', '120'); + return {}; +});`, + ); for (const name of ['falsy', 'island-falsy.island']) { write(`pages/${name}.${extension}`, component('

Broken view

', 'throw undefined;')); } @@ -399,6 +409,39 @@ export default defineMiddleware(async (c, next) => { expect(response.status).toBe(404); expect(await response.text()).toBe('API missing'); }); + it('renders application-defined error statuses on document loads and client visits', async () => { + const page = await browser.newPage(); + page.setDefaultTimeout(8_000); + try { + for (const status of [403, 503]) { + const path = `/status/${status}`; + const documentResponse = await fetch(base + path); + expect(documentResponse.status).toBe(status); + expect(documentResponse.headers.get('Retry-After')).toBe('120'); + expect(await documentResponse.text()).toContain('Status page'); + + await page.goto(base); + await page.waitForSelector('#app[data-hydrated="true"]'); + await page.evaluate(() => + document.querySelector('nav')!.setAttribute('data-retained', 'yes'), + ); + const navigation = page.waitForResponse( + (response) => + response.url() === base + path && + response.request().headers()['x-voidpages'] === 'true', + ); + await page.evaluate(`window.testRouter.visit(${JSON.stringify(path)})`); + const response = await navigation; + expect(response.status()).toBe(status); + expect(response.headers()['x-voidpages']).toBe('true'); + await expect.poll(() => page.locator('h1').textContent()).toBe('Status page'); + expect(await page.locator('nav').getAttribute('data-retained')).toBe('yes'); + } + } finally { + await page.close(); + } + }); + it('keeps the app and outer layout mounted across 404s and client errors', async () => { const page = await browser.newPage(); const errors: Array = []; diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 37be56342..457b1042f 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -1707,10 +1707,12 @@ describe('page loaders and actions composed with defineHandler middleware', () = (format) => { it.each( ([403, 503] as const).flatMap((status) => - (['loader', 'app middleware', 'handler middleware'] as const).map((source) => ({ - status, - source, - })), + (['loader', 'validated loader', 'app middleware', 'handler middleware'] as const).map( + (source) => ({ + status, + source, + }), + ), ), )('renders the page with HTTP $status set by $source', async ({ status, source }) => { const message = status === 403 ? 'Your account is banned' : 'Down for maintenance'; @@ -1720,7 +1722,7 @@ describe('page loaders and actions composed with defineHandler middleware', () = await next(); }); const loader = vi.fn((c: CloudContext) => { - if (source === 'loader') { + if (source === 'loader' || source === 'validated loader') { c.status(status); c.header('Retry-After', '120'); } @@ -1731,15 +1733,25 @@ describe('page loaders and actions composed with defineHandler middleware', () = loader: source === 'handler middleware' ? defineHandler(setStatus, loader) - : defineHandler(loader), + : source === 'validated loader' + ? defineHandler.withValidator({})(loader) + : defineHandler(loader), + }, + async (c, next) => { + c.header('Set-Cookie', 'session=renewed; HttpOnly', { append: true }); + c.header('Set-Cookie', 'locale=en', { append: true }); + if (source === 'app middleware') { + return setStatus(c, next); + } + await next(); }, - source === 'app middleware' ? setStatus : undefined, ); const response = await get(app, format === 'HTML' ? {} : undefined); const body = await response.text(); expect(loader).toHaveBeenCalledTimes(1); expect(response.headers.get('Retry-After')).toBe('120'); + expect(response.headers.getSetCookie()).toEqual(['session=renewed; HttpOnly', 'locale=en']); if (format === 'Pages JSON') { expect(response.headers.get('X-VoidPages')).toBe('true'); expect(decodePageData(JSON.parse(body))).toMatchObject({ @@ -1752,9 +1764,50 @@ describe('page loaders and actions composed with defineHandler middleware', () = } expect(response.status).toBe(status); }); + + it('preserves the loader status while streaming deferred props', async () => { + const app = createPageApp({ + loader: defineHandler((c) => { + c.status(429); + return { slow: defer(async () => 'Try again later') }; + }), + }); + const response = await get(app, format === 'HTML' ? {} : undefined); + const body = await response.text(); + expect(response.status).toBe(429); + if (format === 'Pages JSON') { + expect(response.headers.get('Content-Type')).toBe('application/x-ndjson'); + expect(response.headers.get('X-VoidPages')).toBe('true'); + expect(body).toContain( + serializePageData({ __deferred: 'slow', data: 'Try again later' }), + ); + } else { + expect(body).toContain('__resolveDeferred'); + expect(body).toContain('Try again later'); + } + }); }, ); + it('preserves the loader status when prefetching deferred page data', async () => { + const app = createPageApp({ + loader: defineHandler((c) => { + c.status(503); + return { slow: defer(async () => 'Try again later') }; + }), + }); + const response = await get(app, { + 'X-VoidPages': 'true', + 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION, + Purpose: 'prefetch', + }); + expect(response.status).toBe(503); + expect(response.headers.get('Content-Type')).toBe('application/json'); + expect(decodePageData(await response.json())).toMatchObject({ + props: { slow: 'Try again later' }, + }); + }); + it('re-renders the page after an action with middleware, or shows its field errors', async () => { const action = (message?: string) => defineHandler(