Skip to content
Draft
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
17 changes: 17 additions & 0 deletions docs/guide/pages-routing/loaders.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions packages/void/src/pages/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions packages/void/test/integration/page-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@
`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 =
'<nav data-layout="root"><Link href="/">Home</Link><Link href="/items/returned">Returned</Link><Link href="/items/thrown">Thrown</Link><Link href="/items/unexpected">Unexpected</Link><Link href="/crash">Crash</Link><Link href="/nested/crash">Nested</Link><Link href="/nested/deep/crash">Deep</Link><Link href="/nested/layout">Layout</Link><Link href="/client-only">Client only</Link></nav>';
let rootLayout = component(nav, '', true);
Expand Down Expand Up @@ -119,6 +134,16 @@
write(`pages/_error.${extension}`, component('<h1>Error page</h1><p>{{error.message}}</p>'));
}
write(`pages/items/[id].${extension}`, component('<h1>Item</h1>'));
write(`pages/status/[code].${extension}`, component('<h1>Status page</h1>'));
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('<h1>Broken view</h1>', 'throw undefined;'));
}
Expand Down Expand Up @@ -295,6 +320,46 @@
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');

Check failure on line 344 in packages/void/test/integration/page-errors.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 1/2)

[parallel-integration] packages/void/test/integration/page-errors.test.ts > Pages errors with svelte > renders middleware errors through the root page boundary with HTTP 403 (Pages JSON)

AssertionError: expected null to be 'true' // Object.is equality - Expected: "true" + Received: null ❯ packages/void/test/integration/page-errors.test.ts:344:53

Check failure on line 344 in packages/void/test/integration/page-errors.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 1/2)

[parallel-integration] packages/void/test/integration/page-errors.test.ts > Pages errors with vue > renders middleware errors through the root page boundary with HTTP 503 (Pages JSON)

AssertionError: expected null to be 'true' // Object.is equality - Expected: "true" + Received: null ❯ packages/void/test/integration/page-errors.test.ts:344:53

Check failure on line 344 in packages/void/test/integration/page-errors.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 1/2)

[parallel-integration] packages/void/test/integration/page-errors.test.ts > Pages errors with vue > renders middleware errors through the root page boundary with HTTP 403 (Pages JSON)

AssertionError: expected null to be 'true' // Object.is equality - Expected: "true" + Received: null ❯ packages/void/test/integration/page-errors.test.ts:344:53

Check failure on line 344 in packages/void/test/integration/page-errors.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 1/2)

[parallel-integration] packages/void/test/integration/page-errors.test.ts > Pages errors with react > renders middleware errors through the root page boundary with HTTP 503 (Pages JSON)

AssertionError: expected null to be 'true' // Object.is equality - Expected: "true" + Received: null ❯ packages/void/test/integration/page-errors.test.ts:344:53

Check failure on line 344 in packages/void/test/integration/page-errors.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 1/2)

[parallel-integration] packages/void/test/integration/page-errors.test.ts > Pages errors with react > renders middleware errors through the root page boundary with HTTP 403 (Pages JSON)

AssertionError: expected null to be 'true' // Object.is equality - Expected: "true" + Received: null ❯ packages/void/test/integration/page-errors.test.ts:344:53
expect(deserializePageData(body)).toMatchObject({
props: { error: { status, message } },
shared: { viewer: 'alice' },
url: '/items/unexpected',
});
} else {
expect(response.headers.get('Content-Type')).toContain('text/html');

Check failure on line 351 in packages/void/test/integration/page-errors.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 1/2)

[parallel-integration] packages/void/test/integration/page-errors.test.ts > Pages errors with svelte > renders middleware errors through the root page boundary with HTTP 403 (HTML)

AssertionError: expected 'text/plain; charset=UTF-8' to contain 'text/html' Expected: "text/html" Received: "text/plain; charset=UTF-8" ❯ packages/void/test/integration/page-errors.test.ts:351:54

Check failure on line 351 in packages/void/test/integration/page-errors.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 1/2)

[parallel-integration] packages/void/test/integration/page-errors.test.ts > Pages errors with vue > renders middleware errors through the root page boundary with HTTP 503 (HTML)

AssertionError: expected 'text/plain; charset=UTF-8' to contain 'text/html' Expected: "text/html" Received: "text/plain; charset=UTF-8" ❯ packages/void/test/integration/page-errors.test.ts:351:54

Check failure on line 351 in packages/void/test/integration/page-errors.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 1/2)

[parallel-integration] packages/void/test/integration/page-errors.test.ts > Pages errors with vue > renders middleware errors through the root page boundary with HTTP 403 (HTML)

AssertionError: expected 'text/plain; charset=UTF-8' to contain 'text/html' Expected: "text/html" Received: "text/plain; charset=UTF-8" ❯ packages/void/test/integration/page-errors.test.ts:351:54

Check failure on line 351 in packages/void/test/integration/page-errors.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 1/2)

[parallel-integration] packages/void/test/integration/page-errors.test.ts > Pages errors with react > renders middleware errors through the root page boundary with HTTP 503 (HTML)

AssertionError: expected 'text/plain; charset=UTF-8' to contain 'text/html' Expected: "text/html" Received: "text/plain; charset=UTF-8" ❯ packages/void/test/integration/page-errors.test.ts:351:54

Check failure on line 351 in packages/void/test/integration/page-errors.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 1/2)

[parallel-integration] packages/void/test/integration/page-errors.test.ts > Pages errors with react > renders middleware errors through the root page boundary with HTTP 403 (HTML)

AssertionError: expected 'text/plain; charset=UTF-8' to contain 'text/html' Expected: "text/html" Received: "text/plain; charset=UTF-8" ❯ packages/void/test/integration/page-errors.test.ts:351:54
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],
Expand Down Expand Up @@ -344,6 +409,39 @@
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<string> = [];
Expand Down
100 changes: 100 additions & 0 deletions packages/void/test/unit/page-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,106 @@
});

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) =>
`<main data-component="${page.component}">${JSON.stringify(page.props)}</main>`,
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');

Check failure on line 213 in packages/void/test/unit/page-boundaries.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, unit)

[unit] packages/void/test/unit/page-boundaries.test.ts > Pages loader boundary protocol > HTTP exception recovery through page boundaries (Pages JSON) > renders HTTP 503 through the nearest error boundary

AssertionError: expected null to be 'true' // Object.is equality - Expected: "true" + Received: null ❯ packages/void/test/unit/page-boundaries.test.ts:213:55

Check failure on line 213 in packages/void/test/unit/page-boundaries.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, unit)

[unit] packages/void/test/unit/page-boundaries.test.ts > Pages loader boundary protocol > HTTP exception recovery through page boundaries (Pages JSON) > renders HTTP 429 through the nearest error boundary

AssertionError: expected null to be 'true' // Object.is equality - Expected: "true" + Received: null ❯ packages/void/test/unit/page-boundaries.test.ts:213:55

Check failure on line 213 in packages/void/test/unit/page-boundaries.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, unit)

[unit] packages/void/test/unit/page-boundaries.test.ts > Pages loader boundary protocol > HTTP exception recovery through page boundaries (Pages JSON) > renders HTTP 410 through the nearest error boundary

AssertionError: expected null to be 'true' // Object.is equality - Expected: "true" + Received: null ❯ packages/void/test/unit/page-boundaries.test.ts:213:55

Check failure on line 213 in packages/void/test/unit/page-boundaries.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, unit)

[unit] packages/void/test/unit/page-boundaries.test.ts > Pages loader boundary protocol > HTTP exception recovery through page boundaries (Pages JSON) > renders HTTP 403 through the nearest error boundary

AssertionError: expected null to be 'true' // Object.is equality - Expected: "true" + Received: null ❯ packages/void/test/unit/page-boundaries.test.ts:213:55

Check failure on line 213 in packages/void/test/unit/page-boundaries.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, unit)

[unit] packages/void/test/unit/page-boundaries.test.ts > Pages loader boundary protocol > HTTP exception recovery through page boundaries (Pages JSON) > renders HTTP 401 through the nearest error boundary

AssertionError: expected null to be 'true' // Object.is equality - Expected: "true" + Received: null ❯ packages/void/test/unit/page-boundaries.test.ts:213:55
expect(deserializePageData(body)).toMatchObject({
component: 'items-error',
props: { error: { status, message } },
url: '/items/1',
});
} else {
expect(response.headers.get('Content-Type')).toContain('text/html');

Check failure on line 220 in packages/void/test/unit/page-boundaries.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, unit)

[unit] packages/void/test/unit/page-boundaries.test.ts > Pages loader boundary protocol > HTTP exception recovery through page boundaries (HTML) > renders HTTP 503 through the nearest error boundary

AssertionError: expected 'text/plain;charset=UTF-8' to contain 'text/html' Expected: "text/html" Received: "text/plain;charset=UTF-8" ❯ packages/void/test/unit/page-boundaries.test.ts:220:56

Check failure on line 220 in packages/void/test/unit/page-boundaries.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, unit)

[unit] packages/void/test/unit/page-boundaries.test.ts > Pages loader boundary protocol > HTTP exception recovery through page boundaries (HTML) > renders HTTP 429 through the nearest error boundary

AssertionError: expected 'text/plain;charset=UTF-8' to contain 'text/html' Expected: "text/html" Received: "text/plain;charset=UTF-8" ❯ packages/void/test/unit/page-boundaries.test.ts:220:56

Check failure on line 220 in packages/void/test/unit/page-boundaries.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, unit)

[unit] packages/void/test/unit/page-boundaries.test.ts > Pages loader boundary protocol > HTTP exception recovery through page boundaries (HTML) > renders HTTP 410 through the nearest error boundary

AssertionError: expected 'text/plain;charset=UTF-8' to contain 'text/html' Expected: "text/html" Received: "text/plain;charset=UTF-8" ❯ packages/void/test/unit/page-boundaries.test.ts:220:56

Check failure on line 220 in packages/void/test/unit/page-boundaries.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, unit)

[unit] packages/void/test/unit/page-boundaries.test.ts > Pages loader boundary protocol > HTTP exception recovery through page boundaries (HTML) > renders HTTP 403 through the nearest error boundary

AssertionError: expected 'text/plain;charset=UTF-8' to contain 'text/html' Expected: "text/html" Received: "text/plain;charset=UTF-8" ❯ packages/void/test/unit/page-boundaries.test.ts:220:56

Check failure on line 220 in packages/void/test/unit/page-boundaries.test.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, unit)

[unit] packages/void/test/unit/page-boundaries.test.ts > Pages loader boundary protocol > HTTP exception recovery through page boundaries (HTML) > renders HTTP 401 through the nearest error boundary

AssertionError: expected 'text/plain;charset=UTF-8' to contain 'text/html' Expected: "text/html" Received: "text/plain;charset=UTF-8" ❯ packages/void/test/unit/page-boundaries.test.ts:220:56
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 () => '<main>Error boundary</main>',
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) => {
Expand Down
106 changes: 106 additions & 0 deletions packages/void/test/unit/routing-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1702,6 +1702,112 @@ 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', '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';
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' || source === 'validated loader') {
c.status(status);
c.header('Retry-After', '120');
}
return { message };
});
const app = createPageApp(
{
loader:
source === 'handler middleware'
? defineHandler(setStatus, 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();
},
);
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({
component: 'page',
props: { message },
});
} else {
expect(response.headers.get('Content-Type')).toContain('text/html');
expect(body).toContain(message);
}
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(
Expand Down
Loading