Skip to content

fix: Pages Router prod hydration for inlined page modules#394

Merged
james-elicx merged 7 commits intocloudflare:mainfrom
JaredStowell:jstowell/fix-pages-router-client-manifest
Mar 10, 2026
Merged

fix: Pages Router prod hydration for inlined page modules#394
james-elicx merged 7 commits intocloudflare:mainfrom
JaredStowell:jstowell/fix-pages-router-client-manifest

Conversation

@JaredStowell
Copy link
Contributor

This fixes a Pages Router bug where SSR worked but interactive pages failed to hydrate when the page module was inlined into the client entry chunk.

For affected routes like /counter, the server looked up the page in ssr-manifest.json to decide which client assets to inject. When Vite inlined that page into the main client entry, the page’s SSR manifest entry was emitted as empty, so the server rendered HTML but never loaded the page’s client code. The result was a silent hydration miss in production on Node.

The fix backfills ssr-manifest.json from the emitted client bundle rather than relying only on manifest.json. That lets us map source page modules to the chunk that actually contains them, including:

  • pages emitted as standalone chunks
  • pages inlined into the client entry
  • associated CSS and static assets from Vite metadata
  • basePath / Vite base-prefixed asset URLs

Test coverage was expanded to prove the runtime path:

  • helper-level tests for bundle-based SSR manifest augmentation
  • Pages Router production-build coverage for the inlined /counter case
  • production HTML assertions for basePath-prefixed emitted asset tags
  • end-to-end production coverage for backfilled CSS/static-asset cases

Verification:

  • reproduced the bug on clean HEAD: /counter stayed at Count: 0 and ssr-manifest.json["pages/counter.tsx"] was empty
  • verified the patched build hydrates correctly: /counter updates to Count: 1
  • ran targeted tests for build optimization and Pages Router production behavior

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 10, 2026

Open in StackBlitz

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

commit: 6600d5f

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: Pages Router prod hydration fix for inlined page modules

The diagnosis is correct and the fix is well-targeted. When Vite inlines a page module into the client entry chunk, its ssr-manifest.json entry becomes empty — the server then renders HTML but never injects the client JS, causing a silent hydration miss. The augmentSsrManifestFromBundle approach of walking the emitted bundle to rebuild these mappings is sound.

The test coverage is thorough: unit tests for the augmentation function, integration tests for basePath prefixing, CSS/asset backfilling, and end-to-end production server assertions.

Main concern is the triplication of normalizeManifestFile / manifestFileWithBase across three files. A few smaller items below.

key,
new Set(files.map((file) => normalizeManifestFile(file))),
]),
) as Record<string, Set<string>>;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: The as Record<string, Set<string>> cast is unnecessary since Object.fromEntries + the .map already produce the correct type. TypeScript infers Record<string, Set<string>> from the map callback. Not a blocker.


for (const moduleId of Object.keys(chunk.modules ?? {})) {
const key = normalizeManifestModuleId(moduleId, root);
if (key.startsWith("node_modules/") || key.includes("/node_modules/")) continue;
Copy link
Contributor

Choose a reason for hiding this comment

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

Good filtering — skipping node_modules/ entries prevents bloating the manifest. Worth noting: module IDs starting with \0 (Rollup virtual module prefix, like \0virtual:vinext-client-entry) will pass this filter and get added to the manifest. They're harmless since they'll never match a page lookup key, but you could skip them explicitly for cleanliness:

Suggested change
if (key.startsWith("node_modules/") || key.includes("/node_modules/")) continue;
if (key.startsWith("node_modules/") || key.includes("/node_modules/")) continue;
if (key.startsWith("\0")) continue;


for (const item of Object.values(bundle)) {
if (item.type !== "chunk") continue;
const chunk = item as BundleBackfillChunk;
Copy link
Contributor

Choose a reason for hiding this comment

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

The as BundleBackfillChunk cast after the item.type !== "chunk" guard is safe, but could be cleaner with a type predicate or narrowing the union type. Minor nit, not blocking.


// Extract config values (embedded at build time in the server entry)
const basePath: string = vinextConfig?.basePath ?? "";
const assetBase = basePath ? `${basePath}/` : "/";
Copy link
Contributor

Choose a reason for hiding this comment

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

Good change: computing assetBase from basePath and using it to prefix lazy chunks ensures parity with the Cloudflare closeBundle path. One subtlety — the closeBundle hook in index.ts reads envConfig.base (Vite's base config), while here you derive it from vinextConfig.basePath (Next.js config). These should always match since vinext sets Vite's base from basePath, but it's worth keeping in mind if they ever diverge.

([key]) => key.endsWith("/pages/counter.tsx") || key === "pages/counter.tsx",
);
expect(counterManifestEntry).toBeDefined();
expect(counterManifestEntry?.[1].some((file: string) => file.endsWith(".js"))).toBe(true);
Copy link
Contributor

Choose a reason for hiding this comment

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

Good assertion — verifying the counter page has JS files in its manifest entry proves the backfill worked for the inlined case. One thing missing: there's no assertion that the entry was actually empty before the backfill (i.e., that Vite really did inline the counter into the entry chunk). The test would be more robust if it also checked the build manifest (manifest.json) to confirm counter.tsx has no standalone entry, proving the backfill was necessary. That said, the counterBuildManifestEntries check in the basePath test at line 1223 does exactly this — consider adding the same guard here.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 10, 2026

Review submitted. Here's a summary of the feedback:

Overall: The fix is correct and well-targeted. The augmentSsrManifestFromBundle approach of walking the emitted client bundle to rebuild missing SSR manifest entries is sound. Test coverage is thorough with unit, integration, and end-to-end assertions.

Key feedback points:

  1. Code duplication (main concern): normalizeManifestFile and manifestFileWithBase are copy-pasted identically in 3 files (index.ts, prod-server.ts, deploy.test.ts). The prod-server copy should be a real import to avoid drift.

  2. Silent error swallowing: The catch block in the ssr-manifest-backfill writeBundle hook silently ignores all errors including unexpected ones (permission errors, malformed data). Suggested adding a console.warn.

  3. Virtual module IDs in manifest: Module IDs starting with \0 (Rollup virtual prefix) pass the node_modules filter and get added to the manifest unnecessarily. Suggested adding a \0 prefix skip.

  4. Base path derivation parity: prod-server.ts derives assetBase from vinextConfig.basePath while index.ts reads envConfig.base — these should always match but worth noting if they ever diverge.

  5. Minor nits: Unnecessary type cast on Object.fromEntries, and the existing SSR manifest test could benefit from the same counterBuildManifestEntries guard used in the basePath test to prove the backfill was actually needed.

github run

@james-elicx
Copy link
Collaborator

/bigbonk re-review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: Pages Router prod hydration fix for inlined page modules

This is a well-diagnosed fix for a real production bug. The approach — backfilling ssr-manifest.json from the emitted client bundle in a writeBundle hook — is correct and minimally invasive. The utility extraction to utils/manifest-paths.ts addresses the earlier review's main concern about code triplication.

What I verified:

  • The writeBundle hook correctly runs only for the client build (guarded by ssr-manifest.json existence, which is only emitted with ssrManifest: true on the client build config)
  • The hook runs before closeBundle, so the Cloudflare build integration picks up the augmented manifest
  • assetBase derivation in prod-server.ts (${basePath}/) matches what vinext sets as Vite's base (nextConfig.basePath + "/") — these stay in sync
  • The \0 virtual module prefix skip (line 644) is exercised by the first unit test ("\0virtual:vinext-client-entry" in the modules map)
  • All three former copies of normalizeManifestFile/manifestFileWithBase now import from the shared utils/manifest-paths.ts
  • normalizeManifestModuleId correctly handles both POSIX and Windows absolute paths, with a fallback to the normalized ID for paths outside the root

Test coverage is thorough across all layers: unit tests for augmentSsrManifestFromBundle, integration tests for basePath prefixing and CSS/asset backfilling, and end-to-end production server assertions.

Two minor comments below, neither blocking.

key,
new Set(files.map((file) => normalizeManifestFile(file))),
]),
) as Record<string, Set<string>>;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: the as Record<string, Set<string>> cast isn't needed here — TypeScript infers the correct type from Object.fromEntries + the .map callback. Same applies to line 653 with as Record<string, string[]>. Not blocking, but removing these would be slightly cleaner.


for (const item of Object.values(bundle)) {
if (item.type !== "chunk") continue;
const chunk = item as BundleBackfillChunk;
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: after the item.type !== "chunk" guard, Rollup's OutputChunk type should narrow automatically if you annotate the bundle parameter with the Rollup output types. The as BundleBackfillChunk cast is safe but could be avoided by narrowing the union in the bundle parameter type. Not blocking — the custom BundleBackfillChunk type is pragmatic here since it only declares the fields you actually use.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 10, 2026

Review submitted and approved. Here's a summary of my findings:

Verdict: Approved

The fix is correct and well-targeted. When Vite inlines a page module into the client entry chunk, its ssr-manifest.json entry becomes empty — the server renders HTML but never injects client JS, causing silent hydration failure. The augmentSsrManifestFromBundle approach walks the emitted bundle to rebuild these mappings in a writeBundle hook.

Key things I verified:

  • The hook only fires for client builds (guarded by ssr-manifest.json existence)
  • Hook ordering is correct — writeBundle runs before closeBundle, so Cloudflare integration reads the augmented manifest
  • assetBase in prod-server and envConfig.base in the build plugin stay in sync (both derived from basePath + "/")
  • The \0 virtual module prefix skip is properly tested
  • The earlier review's code triplication concern is fully resolved via utils/manifest-paths.ts

Two non-blocking nits on unnecessary type casts in augmentSsrManifestFromBundle.

github run

@james-elicx james-elicx merged commit f72440f into cloudflare:main Mar 10, 2026
19 checks passed
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.

2 participants