diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index d6d443345..b67affac4 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -48,6 +48,7 @@ import { import { buildDefaultPagesNotFoundResponse } from "./pages-default-404.js"; import { resolvePagesPageMethodResponse } from "./pages-page-method.js"; import { isSerializableProps } from "./pages-serializable-props.js"; +import { loadUserDocumentInitialProps } from "./pages-document-initial-props.js"; /** * Render a React element to a string using renderToReadableStream. @@ -131,7 +132,10 @@ async function streamPageToResponse( let shellTemplate: string; if (DocumentComponent) { - const docElement = React.createElement(DocumentComponent); + const docProps = await loadUserDocumentInitialProps(DocumentComponent); + const docElement = docProps + ? React.createElement(DocumentComponent, docProps) + : React.createElement(DocumentComponent); let docHtml = await renderToStringAsync(docElement); // Replace __NEXT_MAIN__ with our stream marker docHtml = docHtml.replace("__NEXT_MAIN__", STREAM_BODY_MARKER); @@ -1345,7 +1349,10 @@ async function renderErrorPage( } if (DocumentComponent) { - const docElement = createElement(DocumentComponent); + const docProps = await loadUserDocumentInitialProps(DocumentComponent); + const docElement = docProps + ? createElement(DocumentComponent, docProps) + : createElement(DocumentComponent); let docHtml = await renderToStringAsync(docElement); docHtml = docHtml.replace("__NEXT_MAIN__", bodyHtml); docHtml = docHtml.replace("", ""); diff --git a/packages/vinext/src/server/pages-document-initial-props.ts b/packages/vinext/src/server/pages-document-initial-props.ts new file mode 100644 index 000000000..065cce699 --- /dev/null +++ b/packages/vinext/src/server/pages-document-initial-props.ts @@ -0,0 +1,76 @@ +/** + * Pages Router `_document.tsx` `getInitialProps` helper. + * + * Next.js's `pages/_document.tsx` may override + * `static async getInitialProps(ctx)` to inject extra props onto the + * Document element (the classic pattern is + * `await Document.getInitialProps(ctx)` + spread, see Next.js's + * `test/e2e/async-modules/pages/_document.jsx`). The SSR pipeline invokes + * that hook and then renders the Document with the resolved props: + * + * + * + * Reference: + * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/render.tsx + * (search for `loadDocumentInitialProps` and `documentElement`). + * + * vinext only forwards `docProps`. The full `DocumentContext` + * (`renderPage`, `defaultGetInitialProps`, `pathname`, `query`, `req`, `res`, + * `err`, `asPath`) is not yet plumbed through. The common upstream pattern + * + * static async getInitialProps(ctx) { + * const initialProps = await Document.getInitialProps(ctx) + * return { ...initialProps, docValue } + * } + * + * works because the base `Document.getInitialProps` shim in + * `shims/document.tsx` returns `{ html: "" }` and ignores `ctx`. User + * overrides that *only* read `ctx` will see `undefined` fields — that is a + * separate gap tracked alongside the shim TODO. + * + * Returns `null` when the user did not override the base shim (the static + * `getInitialProps` reference still points at the shim's stub) so callers + * skip the spread and render the bare Document element on the fast path. + * + * Errors from a user `getInitialProps` propagate to the caller. Next.js's + * `loadGetInitialProps` does not catch — a throw becomes a 500 — and vinext + * matches that contract so user bugs surface as the loud failures Next.js + * apps already debug against. + */ +import type { ComponentType } from "react"; +// Static import so the identity comparison below is established once at +// module evaluation. A previous version used `await import(...)` per request +// and was flagged by reviewers as unnecessary work — and worse, it left a +// per-request `await` on the fast path where the user had no override. +import BaseDocument from "vinext/shims/document"; + +const BASE_GET_INITIAL_PROPS = ( + BaseDocument as unknown as { + getInitialProps?: unknown; + } +).getInitialProps; + +export async function loadUserDocumentInitialProps( + DocumentComponent: ComponentType, +): Promise | null> { + const getInitialProps = ( + DocumentComponent as unknown as { + getInitialProps?: ( + ctx: unknown, + ) => Promise> | Record; + } + ).getInitialProps; + if (typeof getInitialProps !== "function") return null; + + // Identity check: if the user did not override `static getInitialProps`, + // the inherited reference is the shim's stub. Skip the call so the + // fast path keeps the same number of awaits as before this helper landed. + if (getInitialProps === BASE_GET_INITIAL_PROPS) return null; + + // Pass ctx as `{}`. Most upstream overrides only use ctx to delegate + // back to `Document.getInitialProps`, which the shim ignores. Errors + // propagate — matching Next.js's `loadGetInitialProps`, which has no + // catch and surfaces user bugs as 500s. + const result = await getInitialProps({}); + return result && typeof result === "object" ? (result as Record) : null; +} diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index bed74b9dd..0a526f9ce 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -7,6 +7,7 @@ import { buildRevalidateCacheControl } from "./cache-control.js"; import { setCacheStateHeaders } from "./cache-headers.js"; import { createInlineScriptTag, createNonceAttribute, escapeHtmlAttr } from "./html.js"; import { reportRequestError } from "./instrumentation.js"; +import { loadUserDocumentInitialProps } from "./pages-document-initial-props.js"; import { readStreamAsText } from "../utils/text-stream.js"; type PagesFontPreload = { @@ -156,7 +157,11 @@ async function buildPagesShellHtml( }, ): Promise { if (options.DocumentComponent) { - let html = await options.renderDocumentToString(React.createElement(options.DocumentComponent)); + const docProps = await loadUserDocumentInitialProps(options.DocumentComponent); + const docElement = docProps + ? React.createElement(options.DocumentComponent, docProps) + : React.createElement(options.DocumentComponent); + let html = await options.renderDocumentToString(docElement); html = html.replace("__NEXT_MAIN__", bodyMarker); if (options.ssrHeadHTML || options.assetTags || fontHeadHTML) { html = html.replace( diff --git a/tests/document.test.ts b/tests/document.test.ts index 5ea9adc49..474f516d6 100644 --- a/tests/document.test.ts +++ b/tests/document.test.ts @@ -135,3 +135,63 @@ describe("Document base class", () => { expect(typeof result.html).toBe("string"); }); }); + +// Regression coverage for issue #1361 follow-up: user `_document.tsx` files +// that override `static async getInitialProps` (as the Next.js async-modules +// fixture does) must have those props forwarded to the rendered Document. +// `loadUserDocumentInitialProps` is the SSR-side helper that both the Pages +// Router dev-server and the production response builder call. +// +// Ported from Next.js: test/e2e/async-modules/pages/_document.jsx +// https://github.com/vercel/next.js/blob/canary/test/e2e/async-modules/pages/_document.jsx +describe("loadUserDocumentInitialProps", () => { + it("invokes overridden Document.getInitialProps and returns the resolved props", async () => { + const { loadUserDocumentInitialProps } = + await import("../packages/vinext/src/server/pages-document-initial-props.js"); + class MyDocument extends Document { + static async getInitialProps(_ctx: unknown) { + const base = await Document.getInitialProps(_ctx as never); + return { ...base, docValue: await Promise.resolve("doc value") }; + } + } + const props = await loadUserDocumentInitialProps(MyDocument as React.ComponentType); + expect(props).not.toBeNull(); + expect(props!.docValue).toBe("doc value"); + // The base Document.getInitialProps contract is to return { html: "" } so + // `await Document.getInitialProps(ctx)` spreads `{ html: "" }` into the + // resulting props. This pins that contract — the dev-server / prod + // response builder render `` and consumers may + // read either field. + expect(typeof props!.html).toBe("string"); + }); + + it("returns null when the user did not override the base getInitialProps", async () => { + const { loadUserDocumentInitialProps } = + await import("../packages/vinext/src/server/pages-document-initial-props.js"); + class MyDocument extends Document { + // No getInitialProps override — inherits the base shim's stub. + render() { + return React.createElement("html"); + } + } + const props = await loadUserDocumentInitialProps(MyDocument as React.ComponentType); + expect(props).toBeNull(); + }); + + it("lets errors from the user getInitialProps propagate, matching Next.js render.tsx", async () => { + const { loadUserDocumentInitialProps } = + await import("../packages/vinext/src/server/pages-document-initial-props.js"); + class BadDocument extends Document { + static async getInitialProps(_ctx: unknown): Promise { + throw new Error("boom"); + } + } + // Next.js's `loadGetInitialProps` does NOT catch — a throw surfaces as a + // 500 to the caller. vinext matches that contract so user bugs in + // `_document.tsx`'s getInitialProps are visible instead of silently + // erasing docProps from every render. + await expect(loadUserDocumentInitialProps(BadDocument as React.ComponentType)).rejects.toThrow( + "boom", + ); + }); +}); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index c3223e519..cee09cf14 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -5185,21 +5185,29 @@ export default function handler(_req: any, res: any) { // Class-based Document. Mirrors the original Next.js async-modules // fixture (pages/_document.jsx) which uses `class MyDocument extends - // Document`. This requires the `next/document` default export to be a - // class, not a function — otherwise React refuses to construct - // MyDocument and throws "Class constructor cannot be invoked without - // 'new'", which surfaces in e2e as an empty/500 SSR response. + // Document` and provides `docValue` through a `static async + // getInitialProps()` override that itself uses top-level `await`. This + // requires (a) the `next/document` default export to be a class, not a + // function — otherwise React refuses to construct MyDocument and throws + // "Class constructor cannot be invoked without 'new'", and (b) the SSR + // pipeline to invoke `Document.getInitialProps()` and pass the resolved + // props to the Document element, so `this.props.docValue` is defined at + // render time (Next.js's render.tsx does this in `documentElement`). await fsp.writeFile( path.join(tmpRoot, "pages", "_document.tsx"), `import Document, { Html, Head, Main, NextScript } from "next/document"; const docValue = await Promise.resolve("doc value"); -export default class MyDocument extends Document { +export default class MyDocument extends Document<{ docValue: string }> { + static async getInitialProps(ctx: any) { + const initialProps = await Document.getInitialProps(ctx); + return { ...initialProps, docValue }; + } render() { return ( -
{docValue}
+
{(this.props as any).docValue}
@@ -5210,6 +5218,18 @@ export default class MyDocument extends Document { `, ); + // Custom 404 page using top-level await. Mirrors Next.js async-modules + // pages/404.jsx — the page module must resolve its top-level await before + // the 404 handler renders it. + await fsp.writeFile( + path.join(tmpRoot, "pages", "404.tsx"), + `const content = await Promise.resolve("hi y'all"); +export default function Custom404() { + return

{content}

; +} +`, + ); + await buildPagesFixtureToOutDir(tmpRoot, outDir); const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); @@ -5267,6 +5287,17 @@ export default class MyDocument extends Document { const html = await res.text(); expect(html).toContain('
doc value
'); }); + + // Ported from Next.js: test/e2e/async-modules/index.test.ts + // ('can render async 404 pages') + // The 404 page module uses top-level `await`. When the prod server falls + // through to the custom 404 it must render that module's resolved content. + it("renders a custom 404.tsx whose module uses top-level await", async () => { + const res = await fetch(`${prodUrl}/dhiuhefoiahjeoij`); + expect(res.status).toBe(404); + const html = await res.text(); + expect(html).toContain(`

hi y'all

`); + }); }); describe("router __NEXT_DATA__ correctness (Pages Router)", () => {