Repository navigation
Defer app router HMR refresh until the router is initialized - #100113
Merged
Merged
Conversation
The HMR WebSocket connects before the app router hydrates. A server change reported in that window called hmrRefresh(), which threw "Router action dispatched before initialization" and dropped the refresh, leaving the page stale. Defer the refresh and run it once the router has mounted. Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Contributor
Tests PassedCommit: 8905ccf |
sokra
commented
Oct 13, 2026
The new dev test holds the page before the App Router initializes by stalling the user global-error chunk (referenced as a value in the initial Flight payload), reports two server component changes over the real HMR socket, and asserts the latest content is applied once the router is up, without a full reload. Also assert that every deferred refresh uses the same refresh function. Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Move the deferral from the HotReload component into dispatchAppRouterAction and useActionQueue, where dispatch is initialized. Drop the hot-reloader helper and its effect. Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
sokra
added a commit
that referenced
this pull request
Oct 15, 2026
### What? In webpack dev, manifest files (`*manifest.json` / `*manifest.js`, e.g. `next-font-manifest.json` and `page_client-reference-manifest.js`) are now written atomically: the content goes to a temp file next to the manifest, which is then renamed over it. Other emitted assets are written as before. ### Why? This fixes a canary hard flake in `test/e2e/app-dir/not-found/basic` âš "should render the 404 page when the file is removed, and restore the page when re-added" ([example](https://github.com/vercel/next.js/actions/runs/38003705301/job/114068060547)). It is a real dev-server bug: - Renaming `app/page.js` away makes `/` render `/_not-found`. That entry is already built, so the render doesn't wait for the in-flight rebuild. - Webpack writes emitted assets in place (it truncates first). `RouteModule.loadManifests` can therefore read a 0-byte manifest. Instrumented local runs caught `.next/dev/server/app/_not-found/page_client-reference-manifest.js` (`Manifest file is empty`) and `.next/dev/server/next-font-manifest.json` (`Unexpected end of JSON input`). CI shows the same errors on every retry. - The request returns a 500, the browser lands on the pages error page, which never recovers through app-dir HMR, and the test times out after 120 s. Turbopack already writes its manifests with `writeFileAtomic` and isn't affected. ### How? - `createAtomicManifestOutputFileSystem(fs)` returns an output FS that inherits from webpack's shared `graceful-fs` object without mutating it. Manifest writes go through the existing sync `writeFileAtomic` helper (`lib/fs/write-atomic.ts`, now accepting `Buffer`). That helper is the same one Turbopack's manifests use, and it already handles temp naming, cleanup and the Windows-tolerant `renameSync`. All other writes and methods go to the original FS. - One wrapped FS is created next to the shared `inputFileSystem` and assigned to every compiler in the multi-compiler. The fallback compiler is wrapped too. - This assumes the disk-backed dev output that Next.js uses. The dev server reads these manifests from disk with Node `fs` anyway. ### Verification Full test file, `pnpm test-dev-webpack`, CI env (`NEXT_TEST_REACT_VERSION=18.3.1`, `__NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true`): | | Runs | Manifest read errors | Failures | |---|---|---|---| | canary | 8 | 5 | 5 | | this PR | 10 | 0 | 2* | \* Both remaining failures happened at the *restore* step: an HMR refresh arrived before the app router initialized. That's a separate root cause, fixed independently in #100113, and with both PRs applied the file passed 10/10. - `pnpm test-dev-turbo`: 3/3 passed. `pnpm test-start-webpack`: passed. - After the switch to `writeFileAtomic`, with #100113's fix applied: 5/5 full-file `pnpm test-dev-webpack` runs passed in the same CI env, with no manifest read errors. - Unit tests run against a real temp dir. They cover Buffer bytes being preserved and the target keeping its complete old content at the rename boundary (then the complete new one). They also cover errors reaching the callback exactly once, no leftover temp files, delegation of non-manifest writes, and the original FS not being mutated. <!-- NEXT_JS_LLM --> <!-- fleet e9d6c475-aeff-42d9-a058-210b6631f951 --> --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
sokra
marked this pull request as ready for review
October 15, 2026 07:15
lukesandberg
approved these changes
Oct 20, 2026
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What?
In dev, an app-router HMR refresh that arrives before the app router has initialized is no longer dropped. It now runs once the router mounts. Several early requests are coalesced into the newest one.
Why?
client/app-index.tsxopens the HMR WebSocket before the initial RSC payload resolves and beforehydrateRoot.dispatchis only set whenuseActionQueuerenders. If anADDED_PAGE/REMOVED_PAGE/SERVER_COMPONENT_CHANGES/STATIC_PARAMS_CHANGEDmessage arrives in that window,hmrRefresh()throwsInternal Next.js error: Router action dispatched before initialization.and the refresh is lost. The page then keeps showing stale content until a manual reload.This shows up as the second failure mode of
test/e2e/app-dir/not-found/basicâš "should render the 404 page when the file is removed, and restore the page when re-added". The page is removed, the browser full-reloads to the 404, and the "page re-added" refresh arrives during hydration. The page stays on "Root Not Found" and the restore assertion times out.How?
The race is resolved in
use-action-queue.ts, wheredispatchis initialized:dispatchAppRouterAction: in dev, anACTION_HMR_REFRESHthat arrives whiledispatchis stillnullis stored as the pending HMR refresh instead of throwing. A later early refresh replaces it. ItsAbortSignalbelongs to the newest generation, sincehmrRefreshalready aborts superseded ones.dispatchexists, a new HMR refresh clears any older pending one before dispatching.dispatchis assigned during render, before the mount effect runs, so this stops a stale (already aborted) refresh from being replayed after a newer one.useActionQueuedispatches the pending refresh from a dev-only mount effect, insidestartTransition. It never dispatches during render.Router action dispatched before initialization. Production is unchanged; everything is behind the existingNODE_ENVchecks.hot-reloader-app.tsxis unchanged from canary. The earlierdeferred-hmr-refresh.tshelper, itsHotReloadeffect and its callback invariant are gone.Verification
Full test file,
pnpm test-dev-webpack, CI env (NEXT_TEST_REACT_VERSION=18.3.1,__NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true):before initializationerrorspnpm test-dev-turbopassed 3/3 with both PRs, andpnpm test-start-webpackpassed.New integration test
test/development/app-dir/hmr-refresh-before-hydration:app/global-error.tsx, matched by a marker string in the chunk's source so it works with both bundlers' chunk names.serverComponentChangesmessages, then releases the chunk. It asserts that the latest content appears without a full reload, and that the router-before-initialization error doesn't show up.beforestays, and the router error is thrown for each changeNEXT_TEST_MAX_RETRIES=0)After the move into
use-action-queue.ts:This PR is independent of #100112, which fixes the other root cause of the same flake (a webpack dev manifest read while the file is half-written).