Skip to content

Defer app router HMR refresh until the router is initialized - #100113

Merged
sokra merged 3 commits into
canaryfrom
fleet/app-hmr-refresh-before-router-init
Oct 20, 2026
Merged

sokra merged 3 commits into
canaryfrom
fleet/app-hmr-refresh-before-router-init

Conversation

@sokra

@sokra sokra commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

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 Write webpack dev manifests atomically #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).

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>
@github-actions github-actions Bot added created-by: Turbopack team PRs by the Turbopack team. type: next labels Oct 13, 2026
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 8905ccf

Comment thread packages/next/src/client/dev/hot-reloader/app/deferred-hmr-refresh.test.ts Outdated
@sokra
sokra requested a review from lukesandberg October 13, 2026 15:28
Comment thread packages/next/src/client/dev/hot-reloader/app/deferred-hmr-refresh.ts Outdated
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>
@github-actions github-actions Bot added the tests label Oct 13, 2026
Comment thread packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx Outdated
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
sokra marked this pull request as ready for review October 15, 2026 07:15
@sokra
sokra merged commit 193f030 into canary Oct 20, 2026
220 of 223 checks passed
@sokra
sokra deleted the fleet/app-hmr-refresh-before-router-init branch October 20, 2026 19:33
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