From e11624439be69c9e9309e443b9fc579e14cdcdb3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 27 Mar 2026 00:01:09 +0800 Subject: [PATCH] fix(pages): pass through any Response returned from page loaders Previously, handlePageGet only short-circuited for Response objects with status >= 300 (redirects, 4xx, 5xx). A loader returning a 200 Response (e.g. for content negotiation like Accept: text/markdown) would fall through to extractProps(), which crashes because it expects a plain object, not a Response. Now any Response returned from a loader is passed through directly, regardless of status code. This enables loaders to return custom responses for content negotiation, streaming, or other use cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/react/src/plugin-pages.ts | 9 +++------ packages/void/src/pages/protocol.ts | 15 +++++++++++--- packages/void/src/runtime/response.ts | 29 +++++++++++++++++++-------- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/react/src/plugin-pages.ts b/packages/react/src/plugin-pages.ts index 96c83ce92..4737f052b 100644 --- a/packages/react/src/plugin-pages.ts +++ b/packages/react/src/plugin-pages.ts @@ -139,12 +139,9 @@ ${generateComponentManifest(scan, pagesDir)} const PREAMBLE_SCRIPT = ${JSON.stringify(preambleScript)}; function serializePageData(pageObj) { - return JSON.stringify(pageObj) - .replaceAll("<", "\\u003c") - .replaceAll(">", "\\u003e") - .replaceAll("&", "\\u0026") - .replaceAll("\u2028", "\\u2028") - .replaceAll("\u2029", "\\u2029"); + return JSON.stringify(pageObj).replace(/[<>&\\u2028\\u2029]/g, function(c) { + return String.fromCharCode(92) + "u" + c.charCodeAt(0).toString(16).padStart(4, "0"); + }); } export async function renderPage(pageObj, assetTags) { diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 6b28926c4..e3a6c8ca1 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -1,6 +1,7 @@ import { ValidationError } from "./validation-error"; import { DEFERRED, separateDeferred, type ResolvedDeferredGroup } from "./defer"; import { resolveHead, type HeadDescriptor, type HeadConfig } from "./head"; +import { isConvertedResponse } from "../runtime/response"; export { ValidationError }; @@ -127,9 +128,17 @@ export async function handlePageGet( let props: Record = {}; if (handler) { const result = await handler(c); - if (result instanceof Response) { - const status = result.status; - if (status >= 300) return result; + // 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 ct = result.headers.get("content-type") || ""; + if (result.status >= 300 || !ct.includes("application/json")) return result; } props = await extractProps(result); } diff --git a/packages/void/src/runtime/response.ts b/packages/void/src/runtime/response.ts index 66d732d62..6a3883449 100644 --- a/packages/void/src/runtime/response.ts +++ b/packages/void/src/runtime/response.ts @@ -1,3 +1,15 @@ +/** + * Tracks responses created by convertReturnValue (as opposed to responses + * explicitly constructed by user code). Used by handlePageGet to decide + * whether a Response should be passed through or unwrapped as page props. + */ +const convertedResponses = new WeakSet(); + +/** Returns true if the value is a Response that was created by convertReturnValue. */ +export function isConvertedResponse(value: unknown): boolean { + return value instanceof Response && convertedResponses.has(value); +} + /** * Convert a handler's return value to a Web Response. * @@ -11,16 +23,17 @@ export function convertReturnValue(value: unknown): Response { return value; } + let response: Response; if (value === null || value === undefined) { - return new Response(null, { status: 204 }); - } - - if (typeof value === "string") { - return new Response(value, { + response = new Response(null, { status: 204 }); + } else if (typeof value === "string") { + response = new Response(value, { headers: { "Content-Type": "text/html; charset=utf-8" }, }); + } else { + // object, array, number, boolean → JSON + response = Response.json(value); } - - // object, array, number, boolean → JSON - return Response.json(value); + convertedResponses.add(response); + return response; }