From 8b4ab93e901abf1df19311478ec1a6dbbf11ad48 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 19 Mar 2026 18:40:04 +0000 Subject: [PATCH 1/5] feat: implement next/document with class-based Document and getInitialProps support - Rewrite next/document shim to export DocumentContext, DocumentInitialProps types and a class-based Document with default getInitialProps - Add declare module 'next/document' ambient types in next-shims.d.ts - Wire getInitialProps into dev-server streamPageToResponse via buildDocumentContext helper - Add test fixture _document.tsx with custom getInitialProps that injects a theme prop - Add E2E test verifying the theme prop is rendered on the body element --- packages/vinext/src/server/dev-server.ts | 62 +++++++++++++++- packages/vinext/src/shims/document.tsx | 72 ++++++++++++++++--- packages/vinext/src/shims/next-shims.d.ts | 42 +++++++++++ tests/e2e/pages-router/document.spec.ts | 11 +++ .../fixtures/pages-basic/pages/_document.tsx | 39 ++++++---- 5 files changed, 200 insertions(+), 26 deletions(-) create mode 100644 tests/e2e/pages-router/document.spec.ts diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 4e494ea1e..5c42ec75c 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -83,11 +83,50 @@ const STREAM_BODY_MARKER = ""; * deferring them reduces TTFB and lets the browser start parsing the * shell sooner). */ +/** + * Build a minimal DocumentContext for calling _document.getInitialProps. + * + * Next.js DocumentContext includes renderPage, defaultGetInitialProps, + * req/res, pathname, query, asPath, and locale info. We provide the + * subset that custom documents typically use. + */ +function buildDocumentContext( + req: IncomingMessage, + url: string, +): import("../shims/document.js").DocumentContext { + const defaultGetInitialProps = async (): Promise< + import("../shims/document.js").DocumentInitialProps + > => ({ + html: "", + }); + const [pathname, search] = url.split("?"); + const query: Record = {}; + if (search) { + for (const [k, v] of new URLSearchParams(search)) { + const existing = query[k]; + if (existing !== undefined) { + query[k] = Array.isArray(existing) ? [...existing, v] : [existing, v]; + } else { + query[k] = v; + } + } + } + return { + pathname: pathname || "/", + query, + asPath: url, + req, + renderPage: async () => ({ html: "" }), + defaultGetInitialProps, + }; +} + async function streamPageToResponse( res: ServerResponse, element: React.ReactElement, options: { url: string; + req: IncomingMessage; server: ViteDevServer; fontHeadHTML: string; scripts: string; @@ -100,6 +139,7 @@ async function streamPageToResponse( ): Promise { const { url, + req, server, fontHeadHTML, scripts, @@ -121,7 +161,26 @@ async function streamPageToResponse( let shellTemplate: string; if (DocumentComponent) { - const docElement = React.createElement(DocumentComponent); + // Call getInitialProps if the custom Document class defines it. + // This allows documents to augment props (e.g. inject a theme prop). + let docProps: Record = {}; + const DocClass = DocumentComponent as unknown as { + getInitialProps?: ( + ctx: import("../shims/document.js").DocumentContext, + ) => Promise>; + }; + if (typeof DocClass.getInitialProps === "function") { + try { + const ctx = buildDocumentContext(req, url); + docProps = await DocClass.getInitialProps(ctx); + } catch { + // If getInitialProps fails, fall back to rendering with no props + } + } + const docElement = React.createElement( + DocumentComponent, + docProps as React.ComponentProps, + ); let docHtml = await renderToStringAsync(docElement); // Replace __NEXT_MAIN__ with our stream marker docHtml = docHtml.replace("__NEXT_MAIN__", STREAM_BODY_MARKER); @@ -942,6 +1001,7 @@ hydrate(); // Suspense content streams in as it resolves. await streamPageToResponse(res, element, { url, + req, server, fontHeadHTML, scripts: allScripts, diff --git a/packages/vinext/src/shims/document.tsx b/packages/vinext/src/shims/document.tsx index a66ea97fb..e1834040f 100644 --- a/packages/vinext/src/shims/document.tsx +++ b/packages/vinext/src/shims/document.tsx @@ -4,8 +4,39 @@ * Provides Html, Head, Main, NextScript components for custom _document.tsx. * During SSR these render placeholder markers that the dev server replaces * with actual content. + * + * Also exports DocumentContext, DocumentInitialProps, and the base Document + * class for typed custom document classes that use getInitialProps. */ import React from "react"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type DocumentInitialProps = { + html: string; + head?: Array; + styles?: React.ReactElement[] | Iterable | React.ReactElement; +}; + +export type DocumentContext = { + pathname: string; + query: Record; + asPath?: string; + req?: IncomingMessage; + res?: ServerResponse; + err?: (Error & { statusCode?: number }) | null; + locale?: string; + locales?: readonly string[]; + defaultLocale?: string; + renderPage: () => DocumentInitialProps | Promise; + defaultGetInitialProps( + ctx: DocumentContext, + options?: { nonce?: string }, + ): Promise; +}; + +// ─── Components ─────────────────────────────────────────────────────────────── export function Html({ children, @@ -49,17 +80,36 @@ export function NextScript() { return " }} />; } +// ─── Base Document class ────────────────────────────────────────────────────── + /** - * Default Document component - used when no custom _document.tsx exists. + * Base Document class that custom _document.tsx classes extend. + * + * Provides a default getInitialProps implementation that mirrors Next.js: + * it calls ctx.defaultGetInitialProps(ctx), which in our shim just returns + * an empty html string (the dev server injects actual content separately). + * + * Custom documents can override getInitialProps to augment props: + * + * static async getInitialProps(ctx: DocumentContext): Promise { + * const initialProps = await Document.getInitialProps(ctx); + * return { ...initialProps, theme: "light" }; + * } */ -export default function Document() { - return ( - - - -
- - - - ); +export default class Document

extends React.Component { + static async getInitialProps(ctx: DocumentContext): Promise { + return ctx.defaultGetInitialProps(ctx); + } + + render() { + return ( + + + +

+ + + + ); + } } diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts index d5c7cdf36..f14acded7 100644 --- a/packages/vinext/src/shims/next-shims.d.ts +++ b/packages/vinext/src/shims/next-shims.d.ts @@ -5,6 +5,48 @@ * satisfies TypeScript when one shim imports another (e.g. link -> router). */ +declare module "next/document" { + import type { IncomingMessage, ServerResponse } from "node:http"; + import { Component, type HTMLAttributes, type ReactNode, type ReactElement } from "react"; + + export type DocumentInitialProps = { + html: string; + head?: Array; + styles?: ReactElement[] | Iterable | ReactElement; + }; + + export type DocumentContext = { + pathname: string; + query: Record; + asPath?: string; + req?: IncomingMessage; + res?: ServerResponse; + err?: (Error & { statusCode?: number }) | null; + locale?: string; + locales?: readonly string[]; + defaultLocale?: string; + renderPage: () => DocumentInitialProps | Promise; + defaultGetInitialProps( + ctx: DocumentContext, + options?: { nonce?: string }, + ): Promise; + }; + + export type DocumentProps = DocumentInitialProps & { [key: string]: unknown }; + + export function Html( + props: HTMLAttributes & { children?: ReactNode }, + ): ReactElement; + export function Head(props: { children?: ReactNode }): ReactElement; + export function Main(): ReactElement; + export function NextScript(): ReactElement; + + export default class Document

extends Component { + static getInitialProps(ctx: DocumentContext): Promise; + render(): ReactElement; + } +} + declare module "next/router" { export function useRouter(): any; export function setSSRContext(ctx: any): void; diff --git a/tests/e2e/pages-router/document.spec.ts b/tests/e2e/pages-router/document.spec.ts new file mode 100644 index 000000000..c5aa7e9b0 --- /dev/null +++ b/tests/e2e/pages-router/document.spec.ts @@ -0,0 +1,11 @@ +import { test, expect } from "@playwright/test"; + +const BASE = "http://localhost:4173"; + +test.describe("Document", () => { + test("page includes theme attribute on the body", async ({ page }) => { + await page.goto(`${BASE}/`); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + }); +}); diff --git a/tests/fixtures/pages-basic/pages/_document.tsx b/tests/fixtures/pages-basic/pages/_document.tsx index 0929cc7e9..3b8f9c5b8 100644 --- a/tests/fixtures/pages-basic/pages/_document.tsx +++ b/tests/fixtures/pages-basic/pages/_document.tsx @@ -1,15 +1,26 @@ -import { Html, Head, Main, NextScript } from "next/document"; - -export default function Document() { - return ( - - - - - -

- - - - ); +import DocumentImpl, { Html, Head, Main, NextScript } from "next/document"; +import type { DocumentContext, DocumentInitialProps } from "next/document"; + +type DocumentProps = DocumentInitialProps & { theme: string }; + +export default class Document extends DocumentImpl { + static async getInitialProps(ctx: DocumentContext): Promise { + const initialProps = await DocumentImpl.getInitialProps(ctx); + + return Promise.resolve({ ...initialProps, theme: "light" }); + } + + render() { + return ( + + + + + +
+ + + + ); + } } From 422b60238ed4db44595e890943cf2b80e71d6da8 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 19 Mar 2026 18:59:47 +0000 Subject: [PATCH 2/5] fix: address review comments on next/document getInitialProps - Wire getInitialProps into the production Pages Router entry (pages-server-entry.ts), fixing a dev/prod parity gap where custom document props were silently dropped in production - Wire getInitialProps into renderErrorPage in dev-server.ts so error pages (404, 500) also call the custom document's getInitialProps - Propagate getInitialProps errors instead of silently swallowing them; log and rethrow so bugs are visible - Fix defaultGetInitialProps to call ctx.renderPage() instead of returning a hardcoded empty string, matching Next.js semantics - Pass res (ServerResponse) to buildDocumentContext so custom documents can access ctx.res to set headers - Add E2E tests: error page uses custom document, basic document structure present, getInitialProps pathname context --- .../vinext/src/entries/pages-server-entry.ts | 22 ++++++++- packages/vinext/src/server/dev-server.ts | 46 +++++++++++++------ tests/e2e/pages-router/document.spec.ts | 34 ++++++++++++++ 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index de96eb2db..2f03372b4 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -1021,7 +1021,27 @@ async function _renderPage(request, url, manifest) { var BODY_MARKER = ""; var shellHtml; if (DocumentComponent) { - const docElement = React.createElement(DocumentComponent); + // Call getInitialProps if the custom Document class defines it, + // mirroring the dev-server behavior so props (e.g. a theme) are + // available during rendering in production. + var _docProps = {}; + var _DocClass = DocumentComponent; + if (typeof _DocClass.getInitialProps === "function") { + try { + var _docCtx = { + pathname: url.split("?")[0] || "/", + query: Object.fromEntries(new URL(request.url).searchParams.entries()), + asPath: url, + renderPage: async () => ({ html: "" }), + defaultGetInitialProps: async (ctx) => ctx.renderPage(), + }; + _docProps = await _DocClass.getInitialProps(_docCtx); + } catch (e) { + console.error("[vinext] _document.getInitialProps threw:", e); + throw e; + } + } + const docElement = React.createElement(DocumentComponent, _docProps); shellHtml = await renderToStringAsync(docElement); shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER); if (ssrHeadHTML || assetTags || fontHeadHTML) { diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 5c42ec75c..c3fcbb6de 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -93,12 +93,8 @@ const STREAM_BODY_MARKER = ""; function buildDocumentContext( req: IncomingMessage, url: string, + res?: ServerResponse, ): import("../shims/document.js").DocumentContext { - const defaultGetInitialProps = async (): Promise< - import("../shims/document.js").DocumentInitialProps - > => ({ - html: "", - }); const [pathname, search] = url.split("?"); const query: Record = {}; if (search) { @@ -111,14 +107,19 @@ function buildDocumentContext( } } } - return { + const renderPage = async (): Promise => ({ + html: "", + }); + const ctx: import("../shims/document.js").DocumentContext = { pathname: pathname || "/", query, asPath: url, req, - renderPage: async () => ({ html: "" }), - defaultGetInitialProps, + res, + renderPage, + defaultGetInitialProps: async (c) => c.renderPage(), }; + return ctx; } async function streamPageToResponse( @@ -170,12 +171,8 @@ async function streamPageToResponse( ) => Promise>; }; if (typeof DocClass.getInitialProps === "function") { - try { - const ctx = buildDocumentContext(req, url); - docProps = await DocClass.getInitialProps(ctx); - } catch { - // If getInitialProps fails, fall back to rendering with no props - } + const ctx = buildDocumentContext(req, url, res); + docProps = await DocClass.getInitialProps(ctx); } const docElement = React.createElement( DocumentComponent, @@ -1178,7 +1175,26 @@ async function renderErrorPage( } if (DocumentComponent) { - const docElement = createElement(DocumentComponent); + // Call getInitialProps for error pages too — same as normal pages. + let docErrorProps: Record = {}; + const DocErrorClass = DocumentComponent as unknown as { + getInitialProps?: ( + ctx: import("../shims/document.js").DocumentContext, + ) => Promise>; + }; + if (typeof DocErrorClass.getInitialProps === "function") { + try { + const errCtx = buildDocumentContext(_req, url, res); + docErrorProps = await DocErrorClass.getInitialProps(errCtx); + } catch (e) { + console.error("[vinext] _document.getInitialProps threw during error page render:", e); + throw e; + } + } + const docElement = createElement( + DocumentComponent, + docErrorProps as React.ComponentProps, + ); let docHtml = await renderToStringAsync(docElement); docHtml = docHtml.replace("__NEXT_MAIN__", bodyHtml); docHtml = docHtml.replace("", ""); diff --git a/tests/e2e/pages-router/document.spec.ts b/tests/e2e/pages-router/document.spec.ts index c5aa7e9b0..9579df367 100644 --- a/tests/e2e/pages-router/document.spec.ts +++ b/tests/e2e/pages-router/document.spec.ts @@ -8,4 +8,38 @@ test.describe("Document", () => { await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); }); + + test("error pages (404) also use the custom _document and get getInitialProps", async ({ + page, + }) => { + // The fixture _document adds data-theme-prop via getInitialProps. + // Visiting a nonexistent route triggers renderErrorPage, which should + // also call getInitialProps and wrap with the custom document. + await page.goto(`${BASE}/this-page-does-not-exist`); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + }); + + test("basic document structure is present (id=__next, html/head/body)", async ({ page }) => { + // Regression test: verifies that the document shell renders correctly + // regardless of whether a class-based or function-based document is used. + await page.goto(`${BASE}/`); + + await expect(page.locator("#__next")).toBeVisible(); + // The custom _document renders + const htmlLang = await page.evaluate(() => document.documentElement.lang); + expect(htmlLang).toBe("en"); + }); + + test("getInitialProps receives a pathname via DocumentContext", async ({ page }) => { + // Navigate to /about to verify pathname in context resolves to "/about" + // rather than the root. The fixture's getInitialProps passes the theme + // prop regardless, but the test confirms the request reaches the page + // correctly through the document wrapping. + await page.goto(`${BASE}/about`); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + // The about page content should be present inside the document shell + await expect(page.locator("#__next")).toBeVisible(); + }); }); From 38c458708f99181d1294de86adeedc9feef7c47b Mon Sep 17 00:00:00 2001 From: James Date: Thu, 19 Mar 2026 19:17:32 +0000 Subject: [PATCH 3/5] feat(pages-router): render error pages through custom _document with getInitialProps - Add renderErrorPageResponse() helper in pages-server-entry.ts that routes 404/500 responses through AppComponent and DocumentComponent, calling getInitialProps so custom _document styling and metadata applies to error pages - Replace all plain-HTML 404 fallbacks (no-match, getStaticPaths fallback:false, getServerSideProps notFound, getStaticProps notFound) with renderErrorPageResponse - Add try/catch + console.error around getInitialProps in dev-server.ts for consistency with the prod error-page path - Add cross-reference comment to next-shims.d.ts next/document declaration - Expand next-shims.d.ts in fixture to include full DocumentContext/DocumentProps types and Document base class so _document.tsx can use getInitialProps correctly - Update _document.tsx fixture to propagate ctx.pathname as data-pathname on body - Update document.spec.ts (dev + prod) to assert data-pathname attribute value --- .../vinext/src/entries/pages-server-entry.ts | 100 ++++++++++++++++-- packages/vinext/src/server/dev-server.ts | 15 ++- packages/vinext/src/shims/next-shims.d.ts | 3 + tests/e2e/pages-router-prod/document.spec.ts | 44 ++++++++ tests/e2e/pages-router/document.spec.ts | 9 +- tests/fixtures/pages-basic/next-shims.d.ts | 44 +++++++- .../fixtures/pages-basic/pages/_document.tsx | 10 +- 7 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/pages-router-prod/document.spec.ts diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 2f03372b4..c524ae8a0 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -704,6 +704,89 @@ async function readBodyWithLimit(request, maxBytes) { return chunks.join(""); } +/** + * Render a 404 or 500 error page through the custom _document (if any). + * + * Resolution order mirrors Next.js: + * 404 -> pages/404 -> pages/_error -> plain text fallback + * 500 -> pages/500 -> pages/_error -> plain text fallback + * + * NOTE: In the Cloudflare Workers runtime there is no Node.js IncomingMessage / + * ServerResponse, so ctx.req / ctx.res are not passed to DocumentContext. + * Custom documents that inspect ctx.req will receive undefined — this is an + * intentional limitation for now and is documented here. + */ +async function renderErrorPageResponse(statusCode, url, request) { + var candidates = statusCode === 404 + ? ["/404", "/_error"] + : statusCode === 500 + ? ["/500", "/_error"] + : ["/_error"]; + + for (var ci = 0; ci < candidates.length; ci++) { + var candidate = candidates[ci]; + var errorRoute = null; + for (var ri = 0; ri < pageRoutes.length; ri++) { + if (pageRoutes[ri].pattern === candidate) { errorRoute = pageRoutes[ri]; break; } + } + if (!errorRoute) continue; + var ErrorComponent = errorRoute.module.default; + if (!ErrorComponent) continue; + + var errorProps = { statusCode: statusCode }; + var errorElement; + if (AppComponent) { + errorElement = React.createElement(AppComponent, { Component: ErrorComponent, pageProps: errorProps }); + } else { + errorElement = React.createElement(ErrorComponent, errorProps); + } + errorElement = wrapWithRouterContext(errorElement); + var bodyHtml = await renderToStringAsync(errorElement); + + var html; + if (DocumentComponent) { + var _docErrProps = {}; + var _DocErrClass = DocumentComponent; + if (typeof _DocErrClass.getInitialProps === "function") { + try { + var _docErrCtx = { + // NOTE: req/res are not available in the Workers runtime. + // ctx.req / ctx.res will be undefined for production Workers builds. + pathname: (url || "/").split("?")[0] || "/", + query: request ? Object.fromEntries(new URL(request.url).searchParams.entries()) : {}, + asPath: url || "/", + // renderPage is a no-op in vinext — the body is streamed separately. + // Returning empty html here is an intentional architectural deviation + // from Next.js (where renderPage actually renders the app). CSS-in-JS + // libraries that rely on renderPage for style extraction will get an + // empty html string; see buildDocumentContext in dev-server.ts for more. + renderPage: async () => ({ html: "" }), + defaultGetInitialProps: async (ctx) => ctx.renderPage(), + }; + _docErrProps = await _DocErrClass.getInitialProps(_docErrCtx); + } catch (e) { + console.error("[vinext] _document.getInitialProps threw during error page render:", e); + throw e; + } + } + var docErrElement = React.createElement(DocumentComponent, _docErrProps); + var docErrHtml = await renderToStringAsync(docErrElement); + docErrHtml = docErrHtml.replace("__NEXT_MAIN__", bodyHtml); + docErrHtml = docErrHtml.replace("", ""); + html = docErrHtml; + } else { + html = "\\n\\n\\n \\n \\n\\n\\n
" + bodyHtml + "
\\n\\n"; + } + return new Response(html, { status: statusCode, headers: { "Content-Type": "text/html" } }); + } + + // No custom error page found — plain text fallback + return new Response( + statusCode + " - " + (statusCode === 404 ? "Page not found" : "Internal Server Error"), + { status: statusCode, headers: { "Content-Type": "text/plain" } } + ); +} + export async function renderPage(request, url, manifest, ctx) { if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest)); return _renderPage(request, url, manifest); @@ -733,8 +816,7 @@ async function _renderPage(request, url, manifest) { const match = matchRoute(routeUrl, pageRoutes); if (!match) { - return new Response("

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); + return renderErrorPageResponse(404, routeUrl, request); } const { route, params } = match; @@ -793,8 +875,7 @@ async function _renderPage(request, url, manifest) { }); }); if (!isValidPath) { - return new Response("

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); + return renderErrorPageResponse(404, routeUrl, request); } } } @@ -822,7 +903,7 @@ async function _renderPage(request, url, manifest) { return new Response(null, { status: gsspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); } if (result && result.notFound) { - return new Response("404", { status: 404 }); + return renderErrorPageResponse(404, routeUrl, request); } // Preserve the res object so headers/status/cookies set by gSSP // can be merged into the final HTML response. @@ -963,7 +1044,7 @@ async function _renderPage(request, url, manifest) { return new Response(null, { status: gspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); } if (result && result.notFound) { - return new Response("404", { status: 404 }); + return renderErrorPageResponse(404, routeUrl, request); } if (typeof result.revalidate === "number" && result.revalidate > 0) { isrRevalidateSeconds = result.revalidate; @@ -1029,9 +1110,16 @@ async function _renderPage(request, url, manifest) { if (typeof _DocClass.getInitialProps === "function") { try { var _docCtx = { + // NOTE: req/res are not available in the Workers runtime. + // ctx.req / ctx.res will be undefined for production Workers builds. pathname: url.split("?")[0] || "/", query: Object.fromEntries(new URL(request.url).searchParams.entries()), asPath: url, + // renderPage is a no-op in vinext — the body is streamed separately. + // Returning empty html here is an intentional architectural deviation + // from Next.js (where renderPage actually renders the app). CSS-in-JS + // libraries that rely on renderPage for style extraction will get an + // empty html string; see buildDocumentContext in dev-server.ts for more. renderPage: async () => ({ html: "" }), defaultGetInitialProps: async (ctx) => ctx.renderPage(), }; diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index c3fcbb6de..feaf6dd5e 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -108,6 +108,12 @@ function buildDocumentContext( } } const renderPage = async (): Promise => ({ + // NOTE: renderPage is intentionally a no-op in vinext — the app body is + // streamed separately rather than being rendered inside renderPage as Next.js + // does. This is an intentional architectural deviation: css-in-js libraries + // that rely on renderPage for style extraction (e.g. styled-components) + // will receive an empty html string. The real body content comes from the + // SSR stream that replaces __NEXT_MAIN__ after document rendering. html: "", }); const ctx: import("../shims/document.js").DocumentContext = { @@ -171,8 +177,13 @@ async function streamPageToResponse( ) => Promise>; }; if (typeof DocClass.getInitialProps === "function") { - const ctx = buildDocumentContext(req, url, res); - docProps = await DocClass.getInitialProps(ctx); + try { + const ctx = buildDocumentContext(req, url, res); + docProps = await DocClass.getInitialProps(ctx); + } catch (e) { + console.error("[vinext] _document.getInitialProps threw during page render:", e); + throw e; + } } const docElement = React.createElement( DocumentComponent, diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts index f14acded7..39f83aeea 100644 --- a/packages/vinext/src/shims/next-shims.d.ts +++ b/packages/vinext/src/shims/next-shims.d.ts @@ -6,6 +6,9 @@ */ declare module "next/document" { + // NOTE: Keep these types in sync with shims/document.tsx — the runtime + // implementation. If you update DocumentContext or DocumentInitialProps in + // document.tsx, update them here too (and vice versa). import type { IncomingMessage, ServerResponse } from "node:http"; import { Component, type HTMLAttributes, type ReactNode, type ReactElement } from "react"; diff --git a/tests/e2e/pages-router-prod/document.spec.ts b/tests/e2e/pages-router-prod/document.spec.ts new file mode 100644 index 000000000..0c56c907d --- /dev/null +++ b/tests/e2e/pages-router-prod/document.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from "@playwright/test"; + +const BASE = "http://localhost:4175"; + +test.describe("Document", () => { + test("page includes theme attribute on the body", async ({ page }) => { + await page.goto(`${BASE}/`); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + }); + + test("error pages (404) also use the custom _document and get getInitialProps", async ({ + page, + }) => { + // The fixture _document adds data-theme-prop via getInitialProps. + // Visiting a nonexistent route triggers renderErrorPage, which should + // also call getInitialProps and wrap with the custom document. + await page.goto(`${BASE}/this-page-does-not-exist`); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + }); + + test("basic document structure is present (id=__next, html/head/body)", async ({ page }) => { + // Regression test: verifies that the document shell renders correctly + // regardless of whether a class-based or function-based document is used. + await page.goto(`${BASE}/`); + + await expect(page.locator("#__next")).toBeVisible(); + // The custom _document renders + const htmlLang = await page.evaluate(() => document.documentElement.lang); + expect(htmlLang).toBe("en"); + }); + + test("getInitialProps receives the correct pathname via DocumentContext", async ({ page }) => { + // Navigate to /about and verify that ctx.pathname was correctly set to "/about" + // in DocumentContext — the fixture stores ctx.pathname as data-pathname on . + await page.goto(`${BASE}/about`); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + await expect(page.getAttribute("body", "data-pathname")).resolves.toBe("/about"); + // The about page content should be present inside the document shell + await expect(page.locator("#__next")).toBeVisible(); + }); +}); diff --git a/tests/e2e/pages-router/document.spec.ts b/tests/e2e/pages-router/document.spec.ts index 9579df367..3879399e4 100644 --- a/tests/e2e/pages-router/document.spec.ts +++ b/tests/e2e/pages-router/document.spec.ts @@ -31,14 +31,13 @@ test.describe("Document", () => { expect(htmlLang).toBe("en"); }); - test("getInitialProps receives a pathname via DocumentContext", async ({ page }) => { - // Navigate to /about to verify pathname in context resolves to "/about" - // rather than the root. The fixture's getInitialProps passes the theme - // prop regardless, but the test confirms the request reaches the page - // correctly through the document wrapping. + test("getInitialProps receives the correct pathname via DocumentContext", async ({ page }) => { + // Navigate to /about and verify that ctx.pathname was correctly set to "/about" + // in DocumentContext — the fixture stores ctx.pathname as data-pathname on . await page.goto(`${BASE}/about`); await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + await expect(page.getAttribute("body", "data-pathname")).resolves.toBe("/about"); // The about page content should be present inside the document shell await expect(page.locator("#__next")).toBeVisible(); }); diff --git a/tests/fixtures/pages-basic/next-shims.d.ts b/tests/fixtures/pages-basic/next-shims.d.ts index 6ad0fb5bf..4f08ef3aa 100644 --- a/tests/fixtures/pages-basic/next-shims.d.ts +++ b/tests/fixtures/pages-basic/next-shims.d.ts @@ -129,11 +129,45 @@ declare module "next" { } declare module "next/document" { - import { ComponentType, ReactNode } from "react"; - export const Html: ComponentType<{ lang?: string; children?: ReactNode; [key: string]: unknown }>; - export const Head: ComponentType<{ children?: ReactNode }>; - export const Main: ComponentType; - export const NextScript: ComponentType; + import type { IncomingMessage, ServerResponse } from "node:http"; + import { Component, type HTMLAttributes, type ReactNode, type ReactElement } from "react"; + + export type DocumentInitialProps = { + html: string; + head?: Array; + styles?: ReactElement[] | Iterable | ReactElement; + }; + + export type DocumentContext = { + pathname: string; + query: Record; + asPath?: string; + req?: IncomingMessage; + res?: ServerResponse; + err?: (Error & { statusCode?: number }) | null; + locale?: string; + locales?: readonly string[]; + defaultLocale?: string; + renderPage: () => DocumentInitialProps | Promise; + defaultGetInitialProps( + ctx: DocumentContext, + options?: { nonce?: string }, + ): Promise; + }; + + export type DocumentProps = DocumentInitialProps & { [key: string]: unknown }; + + export function Html( + props: HTMLAttributes & { children?: ReactNode }, + ): ReactElement; + export function Head(props: { children?: ReactNode }): ReactElement; + export function Main(): ReactElement; + export function NextScript(): ReactElement; + + export default class Document

extends Component { + static getInitialProps(ctx: DocumentContext): Promise; + render(): ReactElement; + } } declare module "next/config" { diff --git a/tests/fixtures/pages-basic/pages/_document.tsx b/tests/fixtures/pages-basic/pages/_document.tsx index 3b8f9c5b8..a035555ea 100644 --- a/tests/fixtures/pages-basic/pages/_document.tsx +++ b/tests/fixtures/pages-basic/pages/_document.tsx @@ -1,13 +1,13 @@ import DocumentImpl, { Html, Head, Main, NextScript } from "next/document"; import type { DocumentContext, DocumentInitialProps } from "next/document"; -type DocumentProps = DocumentInitialProps & { theme: string }; +type DocumentProps = DocumentInitialProps & { theme: string; pathname: string }; export default class Document extends DocumentImpl { static async getInitialProps(ctx: DocumentContext): Promise { const initialProps = await DocumentImpl.getInitialProps(ctx); - return Promise.resolve({ ...initialProps, theme: "light" }); + return Promise.resolve({ ...initialProps, theme: "light", pathname: ctx.pathname }); } render() { @@ -16,7 +16,11 @@ export default class Document extends DocumentImpl { - +

From d9509c4d8125f592a4aaf37e237ce428d2f2c60a Mon Sep 17 00:00:00 2001 From: James Date: Thu, 19 Mar 2026 19:50:05 +0000 Subject: [PATCH 4/5] feat(pages-router): implement App.getInitialProps and fix appProps propagation - Add full AppContext/AppInitialProps/AppTree types to next/app shim - Call App.getInitialProps in dev-server, pages-server-entry at all render sites - Serialize appProps into __NEXT_DATA__.props for client hydration - Spread appInitialProps in client/entry.ts initial hydration - Fix router.ts navigateClient to spread appInitialProps on SPA transitions - Fix ISR regen path: hoist _regenAppProps out of if(RegenApp) so it can be included in the freshNextData script - Add pages-router-prod document.spec.ts E2E tests - Update document test to check #app-wrapper (where _app renders data-app-prop) --- packages/vinext/src/client/entry.ts | 6 +- .../vinext/src/entries/pages-server-entry.ts | 34 +++++- packages/vinext/src/server/dev-server.ts | 102 ++++++++++++++++-- packages/vinext/src/shims/app.ts | 72 ++++++++++++- packages/vinext/src/shims/next-shims.d.ts | 41 +++++++ packages/vinext/src/shims/router.ts | 6 +- playwright.config.ts | 4 +- tests/e2e/pages-router-prod/document.spec.ts | 23 ++-- tests/e2e/pages-router/document.spec.ts | 16 +-- tests/fixtures/pages-basic/next-shims.d.ts | 37 ++++++- tests/fixtures/pages-basic/pages/_app.tsx | 10 +- 11 files changed, 308 insertions(+), 43 deletions(-) diff --git a/packages/vinext/src/client/entry.ts b/packages/vinext/src/client/entry.ts index 34d4019f2..69ddbbbf1 100644 --- a/packages/vinext/src/client/entry.ts +++ b/packages/vinext/src/client/entry.ts @@ -19,7 +19,10 @@ import type { VinextNextData } from "./vinext-next-data.js"; // Read the SSR data injected by the server const nextData = window.__NEXT_DATA__ as VinextNextData | undefined; -const pageProps = (nextData?.props.pageProps ?? {}) as Record; +const { pageProps = {}, ...appInitialProps } = (nextData?.props ?? {}) as Record< + string, + unknown +> & { pageProps?: Record }; const pageModulePath = nextData?.__pageModule; const appModulePath = nextData?.__appModule; @@ -51,6 +54,7 @@ async function hydrate() { element = React.createElement(AppComponent, { Component: PageComponent, pageProps, + ...appInitialProps, }); } catch { // No _app, render page directly diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index c524ae8a0..14a61c47a 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -736,7 +736,20 @@ async function renderErrorPageResponse(statusCode, url, request) { var errorProps = { statusCode: statusCode }; var errorElement; if (AppComponent) { - errorElement = React.createElement(AppComponent, { Component: ErrorComponent, pageProps: errorProps }); + var _errAppProps = {}; + if (typeof AppComponent.getInitialProps === "function") { + var _errParsedQuery = request ? Object.fromEntries(new URL(request.url).searchParams.entries()) : {}; + var _errPathname = (url || "/").split("?")[0] || "/"; + try { + _errAppProps = await AppComponent.getInitialProps({ + Component: ErrorComponent, + AppTree: AppComponent, + router: { pathname: _errPathname, query: _errParsedQuery, asPath: url || "/" }, + ctx: { pathname: _errPathname, query: _errParsedQuery, asPath: url || "/" }, + }); + } catch (e) { /* ignore getInitialProps errors on error pages */ } + } + errorElement = React.createElement(AppComponent, { Component: ErrorComponent, pageProps: errorProps, ..._errAppProps }); } else { errorElement = React.createElement(ErrorComponent, errorProps); } @@ -975,7 +988,7 @@ async function _renderPage(request, url, manifest) { // Re-render the page with fresh props inside fresh render sub-scopes // so head/cache state cannot leak across passes. var _el = AppComponent - ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp }) + ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp, ..._appProps }) : React.createElement(PageComponent, _fp); _el = wrapWithRouterContext(_el); var _freshBody = await renderIsrPassToStringAsync(_el); @@ -1053,7 +1066,18 @@ async function _renderPage(request, url, manifest) { let element; if (AppComponent) { - element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + var _appProps = {}; + if (typeof AppComponent.getInitialProps === "function") { + var _parsedQ = parseQuery(routeUrl); + var _pathname = routeUrl.split("?")[0]; + _appProps = await AppComponent.getInitialProps({ + Component: PageComponent, + AppTree: AppComponent, + router: { pathname: _pathname, query: { ...params, ..._parsedQ }, asPath: routeUrl }, + ctx: { pathname: _pathname, query: { ...params, ..._parsedQ }, asPath: routeUrl }, + }); + } + element = React.createElement(AppComponent, { Component: PageComponent, pageProps, ..._appProps }); } else { element = React.createElement(PageComponent, pageProps); } @@ -1083,7 +1107,7 @@ async function _renderPage(request, url, manifest) { const pageModuleIds = route.filePath ? [route.filePath] : []; const assetTags = collectAssetTags(manifest, pageModuleIds); const nextDataPayload = { - props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, + props: { pageProps, ..._appProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, }; if (i18nConfig) { nextDataPayload.locale = locale; @@ -1179,7 +1203,7 @@ async function _renderPage(request, url, manifest) { // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. var isrElement; if (AppComponent) { - isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps, ..._appProps }); } else { isrElement = React.createElement(PageComponent, pageProps); } diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index feaf6dd5e..98343ec5c 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -703,12 +703,44 @@ export function createSSRHandler( } } - let el = RegenApp - ? React.createElement(RegenApp, { - Component: pageModule.default, - pageProps: freshProps, - }) - : React.createElement(pageModule.default, freshProps); + let el: React.ReactElement; + let _regenAppProps: Record = {}; + if (RegenApp) { + if ( + typeof (RegenApp as { getInitialProps?: unknown }).getInitialProps === + "function" + ) { + const _regenParsedQ = parseQuery(url); + const _regenPathname = url.split("?")[0]; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _regenAppProps = + (await (RegenApp as any).getInitialProps({ + Component: pageModule.default, + AppTree: RegenApp, + router: { + pathname: _regenPathname, + query: { ...params, ..._regenParsedQ }, + asPath: url, + }, + ctx: { + pathname: _regenPathname, + query: { ...params, ..._regenParsedQ }, + asPath: url, + }, + })) ?? {}; + } catch { + /* ignore */ + } + } + el = React.createElement(RegenApp, { + Component: pageModule.default, + pageProps: freshProps, + ..._regenAppProps, + }); + } else { + el = React.createElement(pageModule.default, freshProps); + } if (routerShim.wrapWithRouterContext) { el = routerShim.wrapWithRouterContext(el); } @@ -728,7 +760,7 @@ export function createSSRHandler( : null; const freshNextData = ``; const nextDataScript = `'); } } } @@ -987,14 +1021,25 @@ async function _renderPage(request, url, manifest) { } // Re-render the page with fresh props inside fresh render sub-scopes // so head/cache state cannot leak across passes. + var _regenAppProps = {}; + if (AppComponent && typeof AppComponent.getInitialProps === "function") { + try { + _regenAppProps = await AppComponent.getInitialProps({ + Component: PageComponent, + AppTree: AppComponent, + router: { pathname: patternToNextFormat(route.pattern), query: { ...params, ...parseQuery(routeUrl) }, asPath: routeUrl }, + ctx: { pathname: patternToNextFormat(route.pattern), query: { ...params, ...parseQuery(routeUrl) }, asPath: routeUrl }, + }); + } catch(e) { /* ignore getInitialProps errors during regen */ } + } var _el = AppComponent - ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp, ..._appProps }) + ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp, ..._regenAppProps }) : React.createElement(PageComponent, _fp); _el = wrapWithRouterContext(_el); var _freshBody = await renderIsrPassToStringAsync(_el); // Rebuild __NEXT_DATA__ with fresh props var _regenPayload = { - props: { pageProps: _fp }, page: patternToNextFormat(route.pattern), + props: { pageProps: _fp, ..._regenAppProps }, page: patternToNextFormat(route.pattern), query: params, buildId: buildId, isFallback: false, }; if (i18nConfig) { @@ -1105,7 +1150,8 @@ async function _renderPage(request, url, manifest) { } catch (e) { /* font styles not available */ } const pageModuleIds = route.filePath ? [route.filePath] : []; - const assetTags = collectAssetTags(manifest, pageModuleIds); + ${appFilePath !== null ? `pageModuleIds.push(${JSON.stringify(appFilePath.replace(/\\/g, "/"))});` : ""} + const assetTags = collectAssetTags(manifest, pageModuleIds, pageModuleIds); const nextDataPayload = { props: { pageProps, ..._appProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, }; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index db15cdb35..d0b7879f5 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1507,7 +1507,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { appDir = path.join(baseDir, "app"); hasPagesDir = fs.existsSync(pagesDir); hasAppDir = !options.disableAppRouter && fs.existsSync(appDir); - middlewarePath = findMiddlewareFile(root); instrumentationPath = findInstrumentationFile(root); // Load next.config.js if present (always from project root, not src/) @@ -1515,6 +1514,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const rawConfig = await loadNextConfig(root, phase); nextConfig = await resolveNextConfig(rawConfig, root); fileMatcher = createValidFileMatcher(nextConfig.pageExtensions); + middlewarePath = findMiddlewareFile(root, fileMatcher); // Merge env from next.config.js with NEXT_PUBLIC_* env vars const defines = getNextPublicEnvDefines(); diff --git a/packages/vinext/src/server/middleware.ts b/packages/vinext/src/server/middleware.ts index efdce4ad8..5a3bf6916 100644 --- a/packages/vinext/src/server/middleware.ts +++ b/packages/vinext/src/server/middleware.ts @@ -32,6 +32,7 @@ import { NextRequest, NextFetchEvent } from "../shims/server.js"; import { normalizePath } from "./normalize-path.js"; import { shouldKeepMiddlewareHeader } from "./middleware-request-headers.js"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; +import { createValidFileMatcher, type ValidFileMatcher } from "../routing/file-matcher.js"; /** * Determine whether a middleware/proxy file path refers to a proxy file. @@ -73,53 +74,37 @@ export function resolveMiddlewareHandler(mod: Record, filePath: return handler as Function; } -/** - * Possible proxy/middleware file names. - * proxy.ts (Next.js 16) is checked first, then middleware.ts (deprecated). - */ -const PROXY_FILES = [ - "proxy.ts", - "proxy.js", - "proxy.mjs", - "src/proxy.ts", - "src/proxy.js", - "src/proxy.mjs", -]; - -const MIDDLEWARE_FILES = [ - "middleware.ts", - "middleware.tsx", - "middleware.js", - "middleware.mjs", - "src/middleware.ts", - "src/middleware.tsx", - "src/middleware.js", - "src/middleware.mjs", -]; +const MIDDLEWARE_LOCATIONS = ["", "src/"]; /** * Find the proxy or middleware file in the project root. * Checks for proxy.ts (Next.js 16) first, then falls back to middleware.ts. * If middleware.ts is found, logs a deprecation warning. */ -export function findMiddlewareFile(root: string): string | null { + +export function findMiddlewareFile(root: string, fileMatcher?: ValidFileMatcher): string | null { + const matcher = fileMatcher ?? createValidFileMatcher(); // Check proxy.ts first (Next.js 16 replacement for middleware.ts) - for (const file of PROXY_FILES) { - const fullPath = path.join(root, file); - if (fs.existsSync(fullPath)) { - return fullPath; + for (const dir of MIDDLEWARE_LOCATIONS) { + for (const ext of matcher.dottedExtensions) { + const fullPath = path.join(root, dir, `proxy${ext}`); + if (fs.existsSync(fullPath)) { + return fullPath; + } } } // Fall back to middleware.ts (deprecated in Next.js 16) - for (const file of MIDDLEWARE_FILES) { - const fullPath = path.join(root, file); - if (fs.existsSync(fullPath)) { - console.warn( - "[vinext] middleware.ts is deprecated in Next.js 16. " + - "Rename to proxy.ts and export a default or named proxy function.", - ); - return fullPath; + for (const dir of MIDDLEWARE_LOCATIONS) { + for (const ext of matcher.dottedExtensions) { + const fullPath = path.join(root, dir, `middleware${ext}`); + if (fs.existsSync(fullPath)) { + console.warn( + "[vinext] middleware.ts is deprecated in Next.js 16. " + + "Rename to proxy.ts and export a default or named proxy function.", + ); + return fullPath; + } } } return null; diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 6620c5bf2..318b4e263 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -839,6 +839,26 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { if (lazyChunks.length > 0) { globalThis.__VINEXT_LAZY_CHUNKS__ = lazyChunks; } + // Find the client entry file (the chunk that kicks off hydration). + // It is identified as the entry chunk whose source is the virtual client + // entry module. collectAssetTags uses __VINEXT_CLIENT_ENTRY__ to emit the + // '); } } } @@ -19797,6 +19831,102 @@ async function readBodyWithLimit(request, maxBytes) { return chunks.join(""); } +/** + * Render a 404 or 500 error page through the custom _document (if any). + * + * Resolution order mirrors Next.js: + * 404 -> pages/404 -> pages/_error -> plain text fallback + * 500 -> pages/500 -> pages/_error -> plain text fallback + * + * NOTE: In the Cloudflare Workers runtime there is no Node.js IncomingMessage / + * ServerResponse, so ctx.req / ctx.res are not passed to DocumentContext. + * Custom documents that inspect ctx.req will receive undefined — this is an + * intentional limitation for now and is documented here. + */ +async function renderErrorPageResponse(statusCode, url, request) { + var candidates = statusCode === 404 + ? ["/404", "/_error"] + : statusCode === 500 + ? ["/500", "/_error"] + : ["/_error"]; + + for (var ci = 0; ci < candidates.length; ci++) { + var candidate = candidates[ci]; + var errorRoute = null; + for (var ri = 0; ri < pageRoutes.length; ri++) { + if (pageRoutes[ri].pattern === candidate) { errorRoute = pageRoutes[ri]; break; } + } + if (!errorRoute) continue; + var ErrorComponent = errorRoute.module.default; + if (!ErrorComponent) continue; + + var errorProps = { statusCode: statusCode }; + var errorElement; + if (AppComponent) { + var _errAppProps = {}; + if (typeof AppComponent.getInitialProps === "function") { + var _errParsedQuery = request ? Object.fromEntries(new URL(request.url).searchParams.entries()) : {}; + var _errPathname = (url || "/").split("?")[0] || "/"; + try { + _errAppProps = await AppComponent.getInitialProps({ + Component: ErrorComponent, + AppTree: AppComponent, + router: { pathname: _errPathname, query: _errParsedQuery, asPath: url || "/" }, + ctx: { pathname: _errPathname, query: _errParsedQuery, asPath: url || "/" }, + }); + } catch (e) { /* ignore getInitialProps errors on error pages */ } + } + errorElement = React.createElement(AppComponent, { Component: ErrorComponent, pageProps: errorProps, ..._errAppProps }); + } else { + errorElement = React.createElement(ErrorComponent, errorProps); + } + errorElement = wrapWithRouterContext(errorElement); + var bodyHtml = await renderToStringAsync(errorElement); + + var html; + if (DocumentComponent) { + var _docErrProps = {}; + var _DocErrClass = DocumentComponent; + if (typeof _DocErrClass.getInitialProps === "function") { + try { + var _docErrCtx = { + // NOTE: req/res are not available in the Workers runtime. + // ctx.req / ctx.res will be undefined for production Workers builds. + pathname: (url || "/").split("?")[0] || "/", + query: request ? Object.fromEntries(new URL(request.url).searchParams.entries()) : {}, + asPath: url || "/", + // renderPage is a no-op in vinext — the body is streamed separately. + // Returning empty html here is an intentional architectural deviation + // from Next.js (where renderPage actually renders the app). CSS-in-JS + // libraries that rely on renderPage for style extraction will get an + // empty html string; see buildDocumentContext in dev-server.ts for more. + renderPage: async () => ({ html: "" }), + defaultGetInitialProps: async (ctx) => ctx.renderPage(), + }; + _docErrProps = await _DocErrClass.getInitialProps(_docErrCtx); + } catch (e) { + console.error("[vinext] _document.getInitialProps threw during error page render:", e); + throw e; + } + } + var docErrElement = React.createElement(DocumentComponent, _docErrProps); + var docErrHtml = await renderToStringAsync(docErrElement); + docErrHtml = docErrHtml.replace("__NEXT_MAIN__", bodyHtml); + docErrHtml = docErrHtml.replace("", ""); + html = docErrHtml; + } else { + html = "\\n\\n\\n \\n \\n\\n\\n
" + bodyHtml + "
\\n\\n"; + } + return new Response(html, { status: statusCode, headers: { "Content-Type": "text/html" } }); + } + + // No custom error page found — plain text fallback + return new Response( + statusCode + " - " + (statusCode === 404 ? "Page not found" : "Internal Server Error"), + { status: statusCode, headers: { "Content-Type": "text/plain" } } + ); +} + export async function renderPage(request, url, manifest, ctx) { if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest)); return _renderPage(request, url, manifest); @@ -19826,8 +19956,7 @@ async function _renderPage(request, url, manifest) { const match = matchRoute(routeUrl, pageRoutes); if (!match) { - return new Response("

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); + return renderErrorPageResponse(404, routeUrl, request); } const { route, params } = match; @@ -19886,8 +20015,7 @@ async function _renderPage(request, url, manifest) { }); }); if (!isValidPath) { - return new Response("

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); + return renderErrorPageResponse(404, routeUrl, request); } } } @@ -19915,7 +20043,7 @@ async function _renderPage(request, url, manifest) { return new Response(null, { status: gsspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); } if (result && result.notFound) { - return new Response("404", { status: 404 }); + return renderErrorPageResponse(404, routeUrl, request); } // Preserve the res object so headers/status/cookies set by gSSP // can be merged into the final HTML response. @@ -19986,14 +20114,25 @@ async function _renderPage(request, url, manifest) { } // Re-render the page with fresh props inside fresh render sub-scopes // so head/cache state cannot leak across passes. + var _regenAppProps = {}; + if (AppComponent && typeof AppComponent.getInitialProps === "function") { + try { + _regenAppProps = await AppComponent.getInitialProps({ + Component: PageComponent, + AppTree: AppComponent, + router: { pathname: patternToNextFormat(route.pattern), query: { ...params, ...parseQuery(routeUrl) }, asPath: routeUrl }, + ctx: { pathname: patternToNextFormat(route.pattern), query: { ...params, ...parseQuery(routeUrl) }, asPath: routeUrl }, + }); + } catch(e) { /* ignore getInitialProps errors during regen */ } + } var _el = AppComponent - ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp }) + ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp, ..._regenAppProps }) : React.createElement(PageComponent, _fp); _el = wrapWithRouterContext(_el); var _freshBody = await renderIsrPassToStringAsync(_el); // Rebuild __NEXT_DATA__ with fresh props var _regenPayload = { - props: { pageProps: _fp }, page: patternToNextFormat(route.pattern), + props: { pageProps: _fp, ..._regenAppProps }, page: patternToNextFormat(route.pattern), query: params, buildId: buildId, isFallback: false, }; if (i18nConfig) { @@ -20056,7 +20195,7 @@ async function _renderPage(request, url, manifest) { return new Response(null, { status: gspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); } if (result && result.notFound) { - return new Response("404", { status: 404 }); + return renderErrorPageResponse(404, routeUrl, request); } if (typeof result.revalidate === "number" && result.revalidate > 0) { isrRevalidateSeconds = result.revalidate; @@ -20065,7 +20204,18 @@ async function _renderPage(request, url, manifest) { let element; if (AppComponent) { - element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + var _appProps = {}; + if (typeof AppComponent.getInitialProps === "function") { + var _parsedQ = parseQuery(routeUrl); + var _pathname = routeUrl.split("?")[0]; + _appProps = await AppComponent.getInitialProps({ + Component: PageComponent, + AppTree: AppComponent, + router: { pathname: _pathname, query: { ...params, ..._parsedQ }, asPath: routeUrl }, + ctx: { pathname: _pathname, query: { ...params, ..._parsedQ }, asPath: routeUrl }, + }); + } + element = React.createElement(AppComponent, { Component: PageComponent, pageProps, ..._appProps }); } else { element = React.createElement(PageComponent, pageProps); } @@ -20093,9 +20243,10 @@ async function _renderPage(request, url, manifest) { } catch (e) { /* font styles not available */ } const pageModuleIds = route.filePath ? [route.filePath] : []; - const assetTags = collectAssetTags(manifest, pageModuleIds); + pageModuleIds.push("/tests/fixtures/pages-basic/pages/_app.tsx"); + const assetTags = collectAssetTags(manifest, pageModuleIds, pageModuleIds); const nextDataPayload = { - props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, + props: { pageProps, ..._appProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, }; if (i18nConfig) { nextDataPayload.locale = locale; @@ -20114,7 +20265,34 @@ async function _renderPage(request, url, manifest) { var BODY_MARKER = ""; var shellHtml; if (DocumentComponent) { - const docElement = React.createElement(DocumentComponent); + // Call getInitialProps if the custom Document class defines it, + // mirroring the dev-server behavior so props (e.g. a theme) are + // available during rendering in production. + var _docProps = {}; + var _DocClass = DocumentComponent; + if (typeof _DocClass.getInitialProps === "function") { + try { + var _docCtx = { + // NOTE: req/res are not available in the Workers runtime. + // ctx.req / ctx.res will be undefined for production Workers builds. + pathname: url.split("?")[0] || "/", + query: Object.fromEntries(new URL(request.url).searchParams.entries()), + asPath: url, + // renderPage is a no-op in vinext — the body is streamed separately. + // Returning empty html here is an intentional architectural deviation + // from Next.js (where renderPage actually renders the app). CSS-in-JS + // libraries that rely on renderPage for style extraction will get an + // empty html string; see buildDocumentContext in dev-server.ts for more. + renderPage: async () => ({ html: "" }), + defaultGetInitialProps: async (ctx) => ctx.renderPage(), + }; + _docProps = await _DocClass.getInitialProps(_docCtx); + } catch (e) { + console.error("[vinext] _document.getInitialProps threw:", e); + throw e; + } + } + const docElement = React.createElement(DocumentComponent, _docProps); shellHtml = await renderToStringAsync(docElement); shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER); if (ssrHeadHTML || assetTags || fontHeadHTML) { @@ -20164,7 +20342,7 @@ async function _renderPage(request, url, manifest) { // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. var isrElement; if (AppComponent) { - isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps, ..._appProps }); } else { isrElement = React.createElement(PageComponent, pageProps); }