Skip to content

fix(pages-router): invoke Document.getInitialProps so docProps reach SSR#1633

Merged
james-elicx merged 2 commits into
mainfrom
fix/issue-1361-async-modules
May 28, 2026
Merged

fix(pages-router): invoke Document.getInitialProps so docProps reach SSR#1633
james-elicx merged 2 commits into
mainfrom
fix/issue-1361-async-modules

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • Wire static async getInitialProps() on user pages/_document.tsx into the Pages Router SSR pipeline so this.props includes the resolved values at render time. Both the dev-server and the production response builder now invoke the hook and spread the result on the rendered Document element, matching Next.js's <Document {...htmlProps} {...docProps} /> in packages/next/src/server/render.tsx.
  • Extracts a small shared helper loadUserDocumentInitialProps so the dev page render, dev custom-error render, and production buildPagesShellHtml all go through the same code path. The helper short-circuits when the user did not override the base shim, so the fast path keeps its existing number of awaits.

Background

The Next.js test/e2e/async-modules fixture's _document.jsx uses the exact pattern:

class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const initialProps = await Document.getInitialProps(ctx)
    return { ...initialProps, docValue }
  }
  render() { return <Html><div id="doc-value">{this.props.docValue}</div></Html> }
}

PR #1381 fixed the class-construction crash (the shim's default export now extends as a class). After that, the deploy-suite still reported "csr async page modules" failing because <div id="doc-value"> came through empty: the render call was React.createElement(DocumentComponent) with no props, so getInitialProps was never invoked.

This PR closes that gap. Only docProps are forwarded; the full DocumentContext (renderPage, defaultGetInitialProps, pathname, req, res, etc.) is still not plumbed through and is called out in the helper's docstring as a follow-up. The common upstream pattern — await Document.getInitialProps(ctx) + spread — works because the base shim ignores ctx.

Test plan

  • pnpm test tests/document.test.ts — covers loadUserDocumentInitialProps for override, no-override, and throw paths.
  • pnpm test tests/pages-router.test.ts — 253/253 pass, including:
    • the existing "Pages Router top-level await (async modules) in production" block, now with a _document.tsx fixture that uses static async getInitialProps + module-level await, plus a new 404.tsx TLA case.
  • pnpm test tests/pages-page-response.test.ts tests/pages-default-404.test.ts — unaffected suites stay green.
  • pnpm run check — formatting, lint, and type checks pass.
  • Full Vitest / Playwright via CI.

Fixes #1361

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@pkg-pr-new

pkg-pr-new Bot commented May 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1633

commit: 97d26a4

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good extraction — single helper, three call sites, correct fast-path short-circuit for unoverridden shims. Two issues: one incorrect behavioral claim that led to a wrong error-handling policy, and one per-request dynamic import on the hot path that can be avoided.

1. Error handling diverges from Next.js — comment is inaccurate

pages-document-initial-props.ts:72-76 says:

Next.js logs and continues without doc props when the user's getInitialProps throws.

This is incorrect. Next.js's loadGetInitialProps (in packages/next/src/shared/lib/utils.ts) has no try/catch around App.getInitialProps(ctx). If Document.getInitialProps throws, the error propagates uncaught through loadDocumentInitialPropsrenderDocumentrenderToHTMLImpl and surfaces as a 500. Next.js also explicitly throws if the result is falsy or missing .html:

if (!docProps || typeof docProps.html !== 'string') {
  throw new Error(`"${getDisplayName(Document)}.getInitialProps()" should resolve to an object with a "html" prop...`)
}

The current vinext implementation silently swallows the error and renders with no doc props, which means a user whose getInitialProps has a bug sees a subtly broken page instead of an error. This is harder to debug than a 500.

Suggested fix: Let the error propagate (remove the outer try/catch). If there's a deliberate reason to diverge from Next.js here (e.g., defense-in-depth for Workers where a 500 is more expensive), document it as a conscious divergence, not as matching Next.js. The test "swallows errors from the user getInitialProps so renders never crash" should be updated accordingly — either to expect a thrown error, or to document the divergence.

2. await import("vinext/shims/document") runs on every request

loadUserDocumentInitialProps dynamically imports the base shim on every call to compare getInitialProps identity. In dev this is fine (Vite caches modules), but in a production Cloudflare Worker this is a top-level await import() on every page render. The module is always the same — its identity never changes at runtime.

Suggested fix: Cache the resolved base getInitialProps in a module-scoped let so the import runs at most once:

let _baseGetInitialProps: unknown;
let _baseResolved = false;

// inside the function:
if (!_baseResolved) {
  try {
    const docMod = await import("vinext/shims/document");
    _baseGetInitialProps = docMod.default?.getInitialProps ?? null;
  } catch { /* shim resolution failed */ }
  _baseResolved = true;
}
if (_baseGetInitialProps && getInitialProps === _baseGetInitialProps) return null;

This keeps the lazy-init behavior (doesn't import until first call) while avoiding repeated dynamic imports on the hot path.


Everything else looks solid: the helper is well-documented, the three call sites are consistent, the "returns null for unoverridden shim" fast path is correct, the test coverage for the happy path and no-override path is good, and the 404 TLA test is a nice addition.

// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on this catch block says "matches Next.js's tolerant behaviour around document props" — but Next.js does not have tolerant behaviour here. loadGetInitialProps in packages/next/src/shared/lib/utils.ts has no try/catch; a throw from Document.getInitialProps propagates as a 500.

Suggest either: (a) let the error propagate (remove this catch) to match Next.js, or (b) keep the catch but fix the comment to say this is a deliberate vinext divergence and explain why.

const docMod = (await import("vinext/shims/document")) as {
default?: { getInitialProps?: unknown };
};
baseGetInitialProps = docMod.default?.getInitialProps ?? null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This await import("vinext/shims/document") runs on every request. Since the base shim's getInitialProps identity never changes at runtime, cache it in a module-scoped variable so the import executes at most once:

let _baseGetInitialProps: unknown;
let _baseResolved = false;

// then inside the function:
if (!_baseResolved) {
  try {
    const docMod = await import("vinext/shims/document");
    _baseGetInitialProps = docMod.default?.getInitialProps ?? null;
  } catch {}
  _baseResolved = true;
}

@ask-bonk

ask-bonk Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #1633 with request-changes status and two inline comments:

  1. Error handling diverges from Next.js — The catch block at pages-document-initial-props.ts:72-76 claims to match Next.js's tolerant behavior, but Next.js actually lets Document.getInitialProps errors propagate as 500s (no try/catch in loadGetInitialProps). The current code silently swallows errors, making bugs in user getInitialProps overrides harder to debug. Should either let the error propagate or document the divergence honestly.

  2. Per-request dynamic importawait import("vinext/shims/document") at line 60-63 runs on every request to compare getInitialProps identity. Since the base shim never changes at runtime, this should be cached in a module-scoped variable so the import runs at most once.

The rest of the PR — the helper extraction, three consistent call sites, fast-path short-circuit, test coverage, and the 404 TLA addition — looks solid.

github run

james-elicx added a commit that referenced this pull request May 28, 2026
…lper

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
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both prior review issues are resolved:

  1. Error propagation — the try/catch is gone. User getInitialProps errors now propagate to the caller, matching Next.js's loadGetInitialProps which has no catch. The test correctly asserts rejects.toThrow("boom") instead of the previous swallow-and-return-null behavior.

  2. Static import over per-request dynamic importimport BaseDocument from "vinext/shims/document" + a module-scoped BASE_GET_INITIAL_PROPS constant replaces the per-request await import(...). This is cleaner than the lazy-init pattern I would have suggested — the identity is established once at module evaluation, no per-request await on the fast path, and no mutable module-scoped state to reason about.

The helper is well-documented (including the DocumentContext limitation as an explicit follow-up), the three call sites are consistent, and the test coverage hits the three key paths: override, no-override, and error propagation. The 404 TLA addition is a nice bonus ported from the Next.js async-modules fixture.

LGTM.

@ask-bonk

ask-bonk Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Approval review posted on PR #1633. Both prior review issues are resolved — error propagation now matches Next.js's loadGetInitialProps contract, and the per-request dynamic import was replaced with a static import + module-scoped constant. CI checks are passing (some integration tests still pending, no failures).

github run

@james-elicx james-elicx marked this pull request as ready for review May 28, 2026 12:06
…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
…lper

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
@james-elicx james-elicx force-pushed the fix/issue-1361-async-modules branch 2 times, most recently from 8e9d68f to 97d26a4 Compare May 28, 2026 15:44
@james-elicx

Copy link
Copy Markdown
Member Author

Rebased on main; conflict in packages/vinext/src/server/dev-server.ts was a trivial import-list overlap — kept both groups.

@james-elicx james-elicx merged commit 5d9548d into main May 28, 2026
40 of 71 checks passed
@james-elicx james-elicx deleted the fix/issue-1361-async-modules branch May 28, 2026 20:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Async modules (top-level await) render empty pages

1 participant