{
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)", () => {