Skip to content

fix(pages): preload dev hydration modules - #2777

Open
james-elicx wants to merge 6 commits into
mainfrom
codex/pages-complex-hydration
Open

fix(pages): preload dev hydration modules#2777
james-elicx wants to merge 6 commits into
mainfrom
codex/pages-complex-hydration

Conversation

@james-elicx

@james-elicx james-elicx commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

  • preload the initial _app and page modules through ordered parser-visible development scripts
  • install the dev overlay before page evaluation while preserving instrumentation-client and React preamble ordering
  • consume preloaded namespaces synchronously so React handlers exist by the document load event
  • pre-include the Pages client React entrypoints to prevent cold-cache dependency optimizer races
  • keep dev module URLs on Vite base//@fs/, independent of production assetPrefix
  • enable six pages-router-complex tests covering flyouts, cookie radios, history.replaceState, shallow routing, and router events

These six failures had one framework root cause: development hydration began after the document load event because the shared Pages client entry dynamically imported the initial modules. Router/history symptoms were knock-on effects of controls being exposed before hydration, not separate routing bugs.

This PR is temporarily stacked on #2775 because timely hydration exposes that PR's independent raw /_next/data middleware bug in an existing full-navigation assertion. Retarget to main after #2775 merges.

Next.js parity references:

Validation

  • cold optimizer cache, full pages-router-complex project, 7 workers: hydration six passed; no stale optimized-module errors
  • warm optimizer cache, fresh server, full project: 65 passed / 7 skipped; the only failure was corrected in the load-event probe itself and now passes targeted
  • load-event flyout probe after correction: passed
  • focused asset/config/generated-entry suite: 59 passed
  • four dev stylesheet metadata integration regressions: passed
  • package build: passed

@pkg-pr-new

pkg-pr-new Bot commented Jul 31, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2777
npm i https://pkg.pr.new/create-vinext-app@2777
npm i https://pkg.pr.new/@vinext/types@2777
npm i https://pkg.pr.new/vinext@2777

commit: 8ad8bb6

@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
workers-cache preview production
web preview production

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 8ad8bb6 against base 255be4e using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 134.6 KB 134.7 KB ⚫ +0.1%
Client entry size (gzip) vinext 122.1 KB 122.2 KB ⚫ +0.0%
Dev server cold start vinext 2.86 s 2.88 s ⚫ +0.8%
Production build time vinext 3.04 s 3.02 s ⚫ -0.4%
RSC entry closure size (gzip) vinext 113.8 KB 113.9 KB ⚫ +0.0%
Server bundle size (gzip) vinext 191.4 KB 191.5 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx
james-elicx force-pushed the codex/pages-complex-hydration branch from aff40a9 to cf5bdf7 Compare July 31, 2026 11:48
@james-elicx
james-elicx changed the base branch from main to codex/fix-pages-raw-data-middleware July 31, 2026 11:48
@james-elicx
james-elicx force-pushed the codex/pages-complex-hydration branch from cf5bdf7 to ebe9962 Compare July 31, 2026 11:59
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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.

Review: fix(pages): preload dev hydration modules

Reviewed the full diff, traced the runtime paths, and ran the targeted suites (pages-asset-tags, pages-dev-module-url, entry-templates, build-optimization) — all green, lint clean. The core approach is sound: emitting ordered, parser-visible <script type="module"> bootstrap tags so the initial _app/page namespaces are consumed synchronously and hydrateRoot() installs React's delegated handlers before the document load event. The load-event flyout probe in hydration-extras.spec.ts is a good, targeted regression guard for exactly that claim.

A few things I looked at closely and one maintainability concern worth flagging.

1. Fragile implicit coupling between moduleIds order and initialModules indices (maintainability)

The generated client entry hardcodes the array positions:

  • pages-client-entry.ts:254initialModules?.[${hasApp ? 1 : 0}] (page)
  • pages-client-entry.ts:266initialModules?.[0] (app)

These indices are only correct because pages-page-handler.ts:1021-1023 builds pageModuleIds as [appAssetPath?, route.filePath] (app first, page second), and collectAssetTags emits initialModule{i} positionally. There is no test that ties these two ends together — entry-templates.test.ts checks the client entry in isolation, and pages-asset-tags.test.ts checks the tag output in isolation. If someone reorders pageModuleIds, or the app-file gating drifts, hydration silently regresses (page renders from the wrong namespace / falls back to async load, reintroducing the exact post-load-event bug this PR fixes) with no unit failure.

Consider one of:

  • A comment on both sides pointing at the other (// index must match pages-page-handler pageModuleIds order), and/or
  • Emitting a small named map instead of a bare positional array (e.g. keyed by "app"/"page"), so the consumer isn't index-coupled to producer order.

Not blocking, but this is the kind of implicit contract that tends to break later.

2. hasApp (codegen) vs appAssetPath (runtime) — verified consistent on this path

I checked whether hasApp = appFilePath !== null (client entry) could diverge from the runtime appAssetPath, which would shift the index. On the path that actually consumes this bootstrap (pages-server-entrypages-page-handler), appAssetPath is the static _appAssetPath (non-null whenever _app exists), so it matches hasApp. Note dev-server.ts:1551 gates differently (AppComponent ? appFilePath : null), but that path does not call collectAssetTags, so no mismatch. Worth being aware of if the two Pages dev paths are ever unified.

3. Double install of the dev error overlay — safe

The bootstrap script calls installDevErrorOverlay() / reportInitialDevServerErrors(), and the client entry calls them again. installDevErrorOverlay is idempotent (dev-error-overlay.tsx:72 if (installed) return), and the bootstrap /@fs/…/dev-error-overlay.tsx URL resolves to the same module id as the vinext/dev-error-overlay alias, so the installed guard is shared. Fine. reactPreamble / instrumentation-client are likewise imported once across the two bootstrap scripts and re-imported (idempotently) by the entry.

4. Injection safety — good

Module URLs are embedded via safeJsonStringify, which escapes </>/&, so dynamic-route bracket paths can't break out of </script>. Dev-only path regardless.

5. optimizeDeps.include for Pages-only projects — reasonable

Pre-including the React framework entrypoints at the top-level client optimizer (gated !hasAppDir && hasPagesDir) mirrors the per-environment configs and addresses the cold-cache optimizer race. mergeStringArrayValues dedupes via Set. No concern.

6. createPagesDevModuleUrl @fs/ fallback

The out-of-root branch produces /<base>/@fs//abs/path (double slash after @fs on POSIX, since the absolute path starts with /). That's the standard Vite /@fs/ convention and the test asserts it explicitly (pages-dev-module-url.test.ts:32), so it's intended.

Verdict

Approach and implementation look correct; the fix is well targeted and the enabled E2E specs are appropriate. My only real ask is (1) — add a guard/comment or a keyed map to make the moduleIdsinitialModules index contract explicit, since it's currently an untested cross-file invariant that silently reintroduces the bug if broken.

@ask-bonk

ask-bonk Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2777.

Summary

I reviewed the full diff, traced both Pages Router dev paths, and ran the targeted suites (pages-asset-tags, pages-dev-module-url, entry-templates, build-optimization) plus lint — all green.

The approach is sound. Emitting ordered parser-visible <script type="module"> bootstrap tags so the _app/page namespaces are consumed synchronously (letting hydrateRoot() attach React's delegated handlers before the document load event) correctly addresses the shared root cause of the six failing tests. The load-event flyout probe is a well-designed regression guard.

Findings:

  1. Main concern (maintainability, non-blocking): There's an untested implicit contract between pages-page-handler.ts (which orders pageModuleIds as [app, page]) and the generated client entry (which hardcodes initialModules?.[hasApp ? 1 : 0] / [0]). Both ends are unit-tested in isolation but nothing ties them together — reordering either side silently reintroduces the exact post-load-event bug. Suggested a cross-referencing comment or a keyed map instead of a positional array.

  2. Verified hasApp (codegen) vs appAssetPath (runtime) stay consistent on the path that actually uses the bootstrap (the dev-server.ts divergent gating doesn't reach collectAssetTags).

  3. Confirmed the double overlay install is safe (idempotent installed guard + shared module id).

  4. Confirmed injection safety via safeJsonStringify.

  5. optimizeDeps.include and the @fs/ fallback both look correct.

Verdict: approach and implementation are correct; only ask is to make the index contract explicit.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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.

Re-review: fix(pages): preload dev hydration modules

Re-reviewed after 5e94131 (fix(pages): key dev hydration modules by role), which directly addresses the one real ask from my prior review. Traced both Pages dev paths again, re-ran the targeted suites (pages-asset-tags, pages-dev-module-url, entry-templates, build-optimization — 247 passed / 3 skipped) and vp check on the four changed source files (format + lint + types all clean).

Prior finding (fragile positional index contract) — resolved

5e94131 replaces the positional initialModules?.[hasApp ? 1 : 0] / [0] coupling with a role-keyed map:

  • Producer: pages-page-handler.ts:1027-1030 passes devInitialModules: { app: appAssetPath, page: route.filePath }, decoupled from pageModuleIds ordering.
  • Emitter: pages-asset-tags.ts:207-228 iterates ["app","page"] and emits window.__VINEXT_INITIAL_PAGES_MODULES__ = { app, page } by name.
  • Consumer: pages-client-entry.ts:254,266 reads initialModules?.page / initialModules?.app.

Crucially there is now a test that ties both ends together: pages-asset-tags.test.ts "primes development page modules before the shared client entry" deliberately passes moduleIds in the reverse order of devInitialModules and asserts each role imports the correct namespace, and entry-templates.test.ts adds expect(code).not.toContain("initialModules?.["). Reordering either side would now fail a unit test. This is exactly the guard I asked for.

Correctness spot-checks (all pass)

  1. Key consistency for devModuleUrls lookups. devModuleUrls is keyed by toSlash(moduleFilePath) in index.ts:3790, while the handler looks up by route.filePath / appAssetPath. Both originate from the same pagesRouter scan and findFileWithExts(pagesDir, "_app", ...) call, and per the repo's pathslash invariant those ids are already canonical forward-slash — so the lookups hit. No mismatch.

  2. hasApp (codegen) vs appAssetPath (runtime). On the bootstrap-consuming path (pages-server-entrypages-page-handler), appAssetPath is the static _appAssetPath derived from the same appFilePath !== null check that sets hasApp, so app-presence stays in sync and initialModules.app is only read when the appLoader branch is generated. When no _app, moduleRefs.app stays "null" and the client entry never reads it.

  3. Module-execution ordering. The two bootstrap <script type="module"> tags are pushed before the client-entry tags, and ES module scripts execute in document order; static imports in the second bootstrap script run before its body sets window.__VINEXT_INITIAL_PAGES_MODULES__, so the namespaces exist by the time the client entry reads the global. Import ordering (instrumentation → react preamble → overlay) matches the client entry.

  4. Idempotency of double imports. dev-error-overlay (installed guard), instrumentation-client (state overwrite), and the react preamble are all safe to import from both the bootstrap and the client entry — browser module caching runs each body once per resolved URL, and the state writes are overwrite-idempotent.

  5. base/assetPrefix separation. createPagesDevModuleUrl/createPagesDevAssetUrl now thread Vite base and the "keeps development modules on Vite's base instead of assetPrefix" test asserts assetPrefix (cdn.example.com) never leaks into dev module URLs. The /@fs/ out-of-root fallback is covered by pages-dev-module-url.test.ts.

  6. Injection safety. All embedded URLs go through safeJsonStringify; dynamic-route bracket paths can't break out of </script>. Dev-only regardless.

Verdict

Approve. The role-keyed refactor closes the only outstanding concern with a real cross-end test, and the rest of the implementation holds up under scrutiny. Reminder from the PR body: retarget to main after #2775 merges (base is currently codex/fix-pages-raw-data-middleware).

@ask-bonk

ask-bonk Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2777.

Summary

I re-reviewed the PR after the new commit 5e94131 (fix(pages): key dev hydration modules by role), traced both Pages Router dev paths, and ran the targeted suites (pages-asset-tags, pages-dev-module-url, entry-templates, build-optimization — 247 passed / 3 skipped) plus vp check on the four changed source files (all clean).

Prior finding resolved. The previous review's one real ask — the fragile untested positional-index contract between pageModuleIds order and initialModules?.[i] — is now fixed. The code switched to a role-keyed map ({ app, page }), and there's a new test that deliberately reverses moduleIds relative to devInitialModules and asserts the correct namespace under each role, plus not.toContain("initialModules?.[") in the entry-template test. Reordering either side now fails a unit test.

Correctness spot-checks (all pass):

  1. devModuleUrls key consistency (toSlash keys vs handler lookup) holds via the shared pathslash invariant.
  2. hasApp (codegen) vs appAssetPath (runtime) stay in sync on the consuming path.
  3. Module-execution ordering: bootstrap scripts run before the client entry, globals set synchronously before read.
  4. Double imports (overlay/instrumentation/preamble) are idempotent.
  5. assetPrefix correctly does not leak into dev module URLs (test-guarded).
  6. Injection safety via safeJsonStringify.

Verdict: Approve. No blocking issues found. The one prior concern is fully closed with a real cross-end test. Reminder (from PR body): retarget to main after #2775 merges.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 3, 2026 18:21
Base automatically changed from codex/fix-pages-raw-data-middleware to main August 3, 2026 18:26
@james-elicx
james-elicx force-pushed the codex/pages-complex-hydration branch from 5e94131 to b75e10a Compare August 3, 2026 18:37
@james-elicx
james-elicx marked this pull request as draft August 3, 2026 19:36
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx
james-elicx marked this pull request as ready for review August 3, 2026 19:36
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx
james-elicx marked this pull request as draft August 3, 2026 19:38
@james-elicx
james-elicx marked this pull request as ready for review August 3, 2026 19:39
@ask-bonk

ask-bonk Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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.

Re-review: fix(pages): preload dev hydration modules (+ hash-only navigation)

The PR now targets main directly (#2775 merged). Since the two prior ask-bonk reviews, two new commits landed that I focused this pass on:

  • ea6b401 fix(link): preserve hash-only Pages navigation state
  • 86e3183 fix(router): keep changed hashes on the hash-only path

Re-ran the targeted suites — link (130), pages-asset-tags (38), pages-dev-module-url (9/1 skip), entry-templates (52), build-optimization (151/2 skip) — all green, plus vp check on the 7 changed source files (format + lint + types clean).

isHashOnlyBrowserUrlChange — verified against Next.js onlyAHashChange

The core of 86e3183. I diffed it against the authoritative onlyAHashChange in packages/next/src/shared/lib/router/router.ts:2337:

if (newHash && oldUrlNoHash === newUrlNoHash && oldHash === newHash) return true
if (oldUrlNoHash !== newUrlNoHash) return false
return oldHash !== newHash

vinext's rewrite keeps the #-delimiter distinction (absent vs empty fragment) that URL.hash erases, then mirrors the same three branches: same non-empty hash → true (still scrolls), differing pathname/search → false, otherwise oldHash !== newHash. I ran the full truth table (absent/empty/same/diff hash across /p, /p#, /p#a, /p#b) and every case matches Next.js. The <Link href="#"> no-op-remount fix is correct.

Two minor semantic notes (neither a bug):

  • Next.js compares oldUrlNoHash/newUrlNoHash which include the query string; vinext splits pathname and search and requires both to match — equivalent.
  • Next.js uses split('#', 2) (truncates at the first #), while vinext's slice(hashIndex + 1) keeps the remainder of a multi-# fragment. For #a#b the extracted hash differs (a vs a#b), but both still classify it as a hash-only change, so the observable result is identical. Not worth changing.

SSR-time hash resolution (ea6b401) — correct and safe

  • The new pages-router-ssr-context.ts bridges next/link and the server-only router state via a Symbol.for accessor, which is the right pattern given RSC/SSR run next/router and next/link as separate module instances. getPagesRouterSSRContext() returns null in the browser (typeof window !== "undefined" guard), so it only feeds SSR rendering; the browser path still uses window.next.router.asPath.
  • SSRContext (asPath: string; locales?: string[]) is structurally compatible with PagesRouterSSRContext, and the accessor is request-scoped through the existing AsyncLocalStorage state. resolvePagesQueryOnlyHref remains gated on HAS_PAGES_ROUTER, so App-Router state can't leak in.
  • The Router = pagesRouter ?? window.next?.router change (link.tsx:1514) now prefers the mounted RouterContext value. I confirmed that value is built by buildRouterValue(...) in router.ts:3412 with full push/replace/back/forward/reload/prefetch/beforePopState delegating to the singleton, and the "reload" in Router guard still holds — so navigation methods remain intact. Preferring the context matches Next.js Link.
  • resolvePagesLinkNavigationHref (link.tsx:1414) calls resolvePagesQueryOnlyHref without an explicit context, but it only runs inside the click handler (browser), where the window.next.router fallback covers it. Consistent.

Fixture regression guard

data-rendered-at-ms={lifted.renderedAtMs} (gallery) plus the enabled shallow-routing spec asserting the timestamp is unchanged after a sort change is a good, direct proof that gSSP is not re-run on shallow navigation. lifted is correctly un-Omitted from GalleryWallComponentProps and PageLiftedProps.renderedAtMs: number exists.

Prior findings

Both earlier concerns remain resolved: the dev-hydration role-keyed map ({ app, page }) with its cross-end reverse-order test, and the base/assetPrefix separation. Nothing regressed.

Verdict

Approve. The two new commits are well-targeted; the hash-only classifier is a faithful port of Next.js onlyAHashChange (verified against source + full truth table), the SSR-context bridge is sound and browser-safe, and the enabled E2E specs are appropriate regression guards. No blocking issues.

@ask-bonk

ask-bonk Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2777.

Summary

The PR now targets main directly (#2775 merged). Since the two prior reviews, two new commits landed, which I focused this pass on:

  • ea6b401 fix(link): preserve hash-only Pages navigation state
  • 86e3183 fix(router): keep changed hashes on the hash-only path

Verdict: Approve. No blocking issues.

Key verifications:

  1. isHashOnlyBrowserUrlChange vs Next.js onlyAHashChange — I cloned Next.js canary and diffed the rewrite against the authoritative source (router.ts:2337). Ran the full truth table (absent/empty/same/diff hash) and every case matches. The #-delimiter distinction (which URL.hash erases) is correctly preserved so <Link href="#"> takes the history path instead of remounting. Found two harmless semantic notes (query-in-comparison, multi-# split) — neither changes observable behavior.

  2. SSR-context bridge (pages-router-ssr-context.ts) — correct Symbol.for accessor pattern for the RSC/SSR split module instances; returns null in the browser, request-scoped via ALS, and can't leak App-Router state (gated on HAS_PAGES_ROUTER).

  3. Router = pagesRouter ?? window.next?.router — confirmed the mounted RouterContext value carries full navigation methods (buildRouterValue at router.ts:3412), so preferring the context is safe and matches Next.js Link.

  4. Fixture regression guard — the data-rendered-at-ms assertion cleanly proves gSSP isn't re-run on shallow navigation.

  5. Targeted suites (link 130, pages-asset-tags 38, pages-dev-module-url, entry-templates 52, build-optimization 151) all green; vp check on the 7 changed source files clean.

Both prior concerns (role-keyed dev-hydration map, base/assetPrefix separation) remain resolved.

github run

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.

1 participant