Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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__ -->", "");
Expand Down
76 changes: 76 additions & 0 deletions packages/vinext/src/server/pages-document-initial-props.ts
Original file line number Diff line number Diff line change
@@ -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:
*
* <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 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<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;

// 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<string, unknown>) : null;
}
7 changes: 6 additions & 1 deletion packages/vinext/src/server/pages-page-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down
60 changes: 60 additions & 0 deletions tests/document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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("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<never> {
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",
);
});
});
43 changes: 37 additions & 6 deletions tests/pages-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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");
Expand Down Expand Up @@ -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)", () => {
Expand Down
Loading