Repository navigation
Write webpack dev manifests atomically - #100112
Merged
Merged
Conversation
The dev server can read a manifest (e.g. _not-found client reference manifest, next-font-manifest.json) while webpack rewrites it in place, seeing an empty file and responding with a 500. Write manifests to a temp file and rename it over the target, like Turbopack does. Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Contributor
Tests PassedCommit: 00b2823 |
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Member
Author
|
Reviewed b765cb1 |
sokra
marked this pull request as ready for review
October 13, 2026 15:25
Replace the custom async rename/retry/temp-file logic with the existing sync writeFileAtomic helper (now accepting Buffer), and install one wrapped output file system shared by all compilers. Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
lukesandberg
approved these changes
Oct 14, 2026
sokra
added a commit
that referenced
this pull request
Oct 20, 2026
### 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.tsx` opens the HMR WebSocket before the initial RSC payload resolves and before `hydrateRoot`. `dispatch` is only set when `useActionQueue` renders. If an `ADDED_PAGE` / `REMOVED_PAGE` / `SERVER_COMPONENT_CHANGES` / `STATIC_PARAMS_CHANGED` message arrives in that window, `hmrRefresh()` throws `Internal 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`, where `dispatch` is initialized: - `dispatchAppRouterAction`: in dev, an `ACTION_HMR_REFRESH` that arrives while `dispatch` is still `null` is stored as the pending HMR refresh instead of throwing. A later early refresh replaces it. Its `AbortSignal` belongs to the newest generation, since `hmrRefresh` already aborts superseded ones. - Once `dispatch` exists, a new HMR refresh clears any older pending one before dispatching. `dispatch` is assigned during render, before the mount effect runs, so this stops a stale (already aborted) refresh from being replayed after a newer one. - `useActionQueue` dispatches the pending refresh from a dev-only mount effect, inside `startTransition`. It never dispatches during render. - Every other action still throws `Router action dispatched before initialization`. Production is unchanged; everything is behind the existing `NODE_ENV` checks. - `hot-reloader-app.tsx` is unchanged from canary. The earlier `deferred-hmr-refresh.ts` helper, its `HotReload` effect 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`): | | Runs | `before initialization` errors | Failures | |---|---|---|---| | with only #100112 | 10 | 2 | 2 (both at the restore step) | | with #100112 + this PR | 10 | 0 | 0 | - `pnpm test-dev-turbo` passed 3/3 with both PRs, and `pnpm test-start-webpack` passed. New integration test `test/development/app-dir/hmr-refresh-before-hydration`: - It holds the page before the router initializes by stalling the chunk of a user `app/global-error.tsx`, matched by a marker string in the chunk's source so it works with both bundlers' chunk names. - The initial Flight payload references the global error component as a *value*, which blocks the payload until that chunk loads. - A client component rendered as an element wouldn't work, because element references are lazy. - The main runtime still loads and opens the real HMR socket. - The test edits the server page twice, waits until the browser has received both `serverComponentChanges` messages, 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. | | webpack dev | Turbopack dev | |---|---|---| | without the fix | fails: `before` stays, and the router error is thrown for each change | same | | with the fix (`NEXT_TEST_MAX_RETRIES=0`) | 5/5 pass | 5/5 pass | After the move into `use-action-queue.ts`: - The integration test passed 5/5 again on both bundlers. - Disabling the deferral makes it fail again on webpack. - The full not-found file passed 3/3 with #100112 applied, in the CI env. - A local harness (not committed) checked the ordering where a newer refresh arrives after render but before the mount effect: removing the clear makes exactly that case fail. 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). <!-- 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>
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 webpack dev, manifest files (
*manifest.json/*manifest.js, e.g.next-font-manifest.jsonandpage_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). It is a real dev-server bug:app/page.jsaway makes/render/_not-found. That entry is already built, so the render doesn't wait for the in-flight rebuild.RouteModule.loadManifestscan 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.Turbopack already writes its manifests with
writeFileAtomicand isn't affected.How?
createAtomicManifestOutputFileSystem(fs)returns an output FS that inherits from webpack's sharedgraceful-fsobject without mutating it. Manifest writes go through the existing syncwriteFileAtomichelper (lib/fs/write-atomic.ts, now acceptingBuffer). That helper is the same one Turbopack's manifests use, and it already handles temp naming, cleanup and the Windows-tolerantrenameSync. All other writes and methods go to the original FS.inputFileSystemand assigned to every compiler in the multi-compiler. The fallback compiler is wrapped too.fsanyway.Verification
Full test file,
pnpm test-dev-webpack, CI env (NEXT_TEST_REACT_VERSION=18.3.1,__NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true):* 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.writeFileAtomic, with Defer app router HMR refresh until the router is initialized #100113's fix applied: 5/5 full-filepnpm test-dev-webpackruns passed in the same CI env, with no manifest read errors.