Skip to content

Write webpack dev manifests atomically - #100112

Merged
sokra merged 3 commits into
canaryfrom
fleet/webpack-dev-atomic-manifest-writes
Oct 15, 2026
Merged

sokra merged 3 commits into
canaryfrom
fleet/webpack-dev-atomic-manifest-writes

Conversation

@sokra

@sokra sokra commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

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). 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 Defer app router HMR refresh until the router is initialized #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.

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>
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 00b2823

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>

sokra commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Reviewed b765cb1

@sokra
sokra marked this pull request as ready for review October 13, 2026 15:25
@sokra
sokra requested a review from lukesandberg October 13, 2026 15:26
Comment thread packages/next/src/server/dev/atomic-manifest-output-fs.ts Outdated
Comment thread packages/next/src/server/dev/atomic-manifest-output-fs.ts Outdated
Comment thread packages/next/src/server/dev/hot-reloader-webpack.ts Outdated
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>
@sokra
sokra requested a review from lukesandberg October 14, 2026 09:57
@sokra
sokra merged commit a5fe506 into canary Oct 15, 2026
221 of 223 checks passed
@sokra
sokra deleted the fleet/webpack-dev-atomic-manifest-writes branch October 15, 2026 05:17
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants