From aa3b56264ade2443246fa122b467d26d61e9368f Mon Sep 17 00:00:00 2001
From: James <james@eli.cx>
Date: Thu, 28 May 2026 12:40:40 +0100
Subject: [PATCH 1/2] fix(pages-router): invoke Document.getInitialProps so
 docProps reach SSR (#1361)

`pages/_document.tsx` files that override `static async getInitialProps`
to inject extra props were rendered with an empty `this.props`, because
both the dev-server and the production pages response builder created
the Document element with `React.createElement(DocumentComponent)` and
never called the hook.

The Next.js async-modules e2e fixture
(`test/e2e/async-modules/pages/_document.jsx`) uses exactly that pattern
to surface a `docValue` from `await Document.getInitialProps(ctx)` plus
a top-level `await` constant. PR #1381 (issue #1361) fixed the class
construction crash, but the deploy-suite continued to fail
"csr async page modules" because `<div id="doc-value">` rendered empty.

This change adds a small `loadUserDocumentInitialProps` helper used by
both the Pages Router dev-server (`streamPageToResponse` shell and the
custom-error-page shell) and the production response builder
(`buildPagesShellHtml`). It invokes the override (if any) and spreads
the resolved props on the Document element, mirroring Next.js's
`render.tsx` `<Document {...htmlProps} {...docProps} />` call. The
helper short-circuits when the user did not override the base shim, so
the existing fast path keeps the same number of awaits.

Reference: vercel/next.js packages/next/src/server/render.tsx
(`loadDocumentInitialProps`, `documentElement`).

Tests:
- Adds a new pages-router production e2e case that builds a
  `_document.tsx` with `static async getInitialProps` + top-level
  `await`, plus a custom `404.tsx` that uses top-level `await`, and
  asserts the resolved values reach the rendered HTML.
- Unit-tests `loadUserDocumentInitialProps` directly for the override,
  no-override, and throwing-override cases.

Refs #1361
---
 packages/vinext/src/server/dev-server.ts      | 11 ++-
 .../server/pages-document-initial-props.ts    | 78 +++++++++++++++++++
 .../vinext/src/server/pages-page-response.ts  |  7 +-
 tests/document.test.ts                        | 55 +++++++++++++
 tests/pages-router.test.ts                    | 43 ++++++++--
 5 files changed, 185 insertions(+), 9 deletions(-)
 create mode 100644 packages/vinext/src/server/pages-document-initial-props.ts

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("<!-- __NEXT_SCRIPTS__ -->", "");
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..c15b9feb5
--- /dev/null
+++ b/packages/vinext/src/server/pages-document-initial-props.ts
@@ -0,0 +1,78 @@
+/**
+ * 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:
+ *
+ *   <Document {...htmlProps} {...docProps} />
+ *
+ * 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 Document has no `getInitialProps` (unusual — the shim defines one),
+ *   - The user did not override the base shim (so the call would be a no-op),
+ *   - The override threw (logged and swallowed; matches Next.js's tolerant
+ *     behaviour around document props).
+ *
+ * Callers should treat `null` as "render the bare Document element" so the
+ * fast-path stays an extra zero allocations.
+ */
+import type { ComponentType } from "react";
+
+export async function loadUserDocumentInitialProps(
+  DocumentComponent: ComponentType,
+): Promise<Record<string, unknown> | null> {
+  const getInitialProps = (
+    DocumentComponent as unknown as {
+      getInitialProps?: (
+        ctx: unknown,
+      ) => Promise<Record<string, unknown>> | Record<string, unknown>;
+    }
+  ).getInitialProps;
+  if (typeof getInitialProps !== "function") return null;
+
+  // Detect "user didn't override the base shim" so we don't allocate on the
+  // hot path for every render. If the shim resolution fails (production
+  // bundling can rewrite paths) we still invoke whatever's present —
+  // the base shim itself is a cheap `Promise.resolve({ html: "" })`.
+  let baseGetInitialProps: unknown = null;
+  try {
+    const docMod = (await import("vinext/shims/document")) as {
+      default?: { getInitialProps?: unknown };
+    };
+    baseGetInitialProps = docMod.default?.getInitialProps ?? null;
+  } catch {
+    // shim resolution failed — fall through.
+  }
+  if (baseGetInitialProps && getInitialProps === baseGetInitialProps) return null;
+
+  try {
+    const result = await getInitialProps({});
+    return result && typeof result === "object" ? (result as Record<string, unknown>) : null;
+  } catch (err) {
+    // Surface but don't fail the render: Next.js logs and continues without
+    // doc props when the user's getInitialProps throws.
+    console.error("[vinext] Document.getInitialProps threw:", err);
+    return 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<string> {
   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..3ecf517b2 100644
--- a/tests/document.test.ts
+++ b/tests/document.test.ts
@@ -135,3 +135,58 @@ 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 `<Document {...docProps} />` 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("swallows errors from the user getInitialProps so renders never crash", async () => {
+    const { loadUserDocumentInitialProps } =
+      await import("../packages/vinext/src/server/pages-document-initial-props.js");
+    class BadDocument extends Document {
+      static async getInitialProps(_ctx: unknown): Promise<never> {
+        throw new Error("boom");
+      }
+    }
+    const props = await loadUserDocumentInitialProps(BadDocument as React.ComponentType);
+    expect(props).toBeNull();
+  });
+});
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 (
       <Html>
         <Head />
         <body>
-          <div id="doc-value">{docValue}</div>
+          <div id="doc-value">{(this.props as any).docValue}</div>
           <Main />
           <NextScript />
         </body>
@@ -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 <h1 id="content-404">{content}</h1>;
+}
+`,
+    );
+
     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('<div id="doc-value">doc value</div>');
   });
+
+  // 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(`<h1 id="content-404">hi y&#x27;all</h1>`);
+  });
 });
 
 describe("router __NEXT_DATA__ correctness (Pages Router)", () => {

From 97d26a4330b74e5d74268cd4d027296ed5ab9fae Mon Sep 17 00:00:00 2001
From: James <james@eli.cx>
Date: Thu, 28 May 2026 13:03:31 +0100
Subject: [PATCH 2/2] fix(pages-router): address bonk review on
 Document.getInitialProps helper
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Two changes from review feedback on PR #1633:

1. Cache the base getInitialProps reference at module level. The previous
   helper did `await import("vinext/shims/document")` on every request to
   compare identity — unnecessary per-request work and an extra await on
   the fast path where the user has no override. Switched to a static
   import + module-scoped constant so the identity check is a synchronous
   reference compare. No-override callers now resolve with one less await.

2. Let user `getInitialProps` errors propagate. Next.js's `loadGetInitialProps`
   does not catch — a throw surfaces as a 500 to the caller. The previous
   version logged-and-swallowed the error and returned null, which
   silently erased docProps on every failing render and made user bugs
   harder to find. vinext now matches Next.js's contract.

Updated the corresponding unit test to assert the throw propagates rather
than that the helper resolves to null.

Refs #1361, #1633
---
 .../server/pages-document-initial-props.ts    | 58 +++++++++----------
 tests/document.test.ts                        | 11 +++-
 2 files changed, 36 insertions(+), 33 deletions(-)

diff --git a/packages/vinext/src/server/pages-document-initial-props.ts b/packages/vinext/src/server/pages-document-initial-props.ts
index c15b9feb5..065cce699 100644
--- a/packages/vinext/src/server/pages-document-initial-props.ts
+++ b/packages/vinext/src/server/pages-document-initial-props.ts
@@ -28,16 +28,27 @@
  * overrides that *only* read `ctx` will see `undefined` fields — that is a
  * separate gap tracked alongside the shim TODO.
  *
- * Returns `null` when:
- *   - The Document has no `getInitialProps` (unusual — the shim defines one),
- *   - The user did not override the base shim (so the call would be a no-op),
- *   - The override threw (logged and swallowed; matches Next.js's tolerant
- *     behaviour around document props).
+ * 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.
  *
- * Callers should treat `null` as "render the bare Document element" so the
- * fast-path stays an extra zero allocations.
+ * 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,
@@ -51,28 +62,15 @@ export async function loadUserDocumentInitialProps(
   ).getInitialProps;
   if (typeof getInitialProps !== "function") return null;
 
-  // Detect "user didn't override the base shim" so we don't allocate on the
-  // hot path for every render. If the shim resolution fails (production
-  // bundling can rewrite paths) we still invoke whatever's present —
-  // the base shim itself is a cheap `Promise.resolve({ html: "" })`.
-  let baseGetInitialProps: unknown = null;
-  try {
-    const docMod = (await import("vinext/shims/document")) as {
-      default?: { getInitialProps?: unknown };
-    };
-    baseGetInitialProps = docMod.default?.getInitialProps ?? null;
-  } catch {
-    // shim resolution failed — fall through.
-  }
-  if (baseGetInitialProps && getInitialProps === baseGetInitialProps) 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;
 
-  try {
-    const result = await getInitialProps({});
-    return result && typeof result === "object" ? (result as Record<string, unknown>) : null;
-  } catch (err) {
-    // Surface but don't fail the render: Next.js logs and continues without
-    // doc props when the user's getInitialProps throws.
-    console.error("[vinext] Document.getInitialProps threw:", err);
-    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<string, unknown>) : null;
 }
diff --git a/tests/document.test.ts b/tests/document.test.ts
index 3ecf517b2..474f516d6 100644
--- a/tests/document.test.ts
+++ b/tests/document.test.ts
@@ -178,7 +178,7 @@ describe("loadUserDocumentInitialProps", () => {
     expect(props).toBeNull();
   });
 
-  it("swallows errors from the user getInitialProps so renders never crash", async () => {
+  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 {
@@ -186,7 +186,12 @@ describe("loadUserDocumentInitialProps", () => {
         throw new Error("boom");
       }
     }
-    const props = await loadUserDocumentInitialProps(BadDocument as React.ComponentType);
-    expect(props).toBeNull();
+    // 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",
+    );
   });
 });
