Skip to content

fix(dev): prevent HMR updates from being dropped during concurrent/slow network conditions#14953

Open
NYCU-Chung wants to merge 2 commits intoremix-run:devfrom
NYCU-Chung:fix/hmr-race-condition
Open

fix(dev): prevent HMR updates from being dropped during concurrent/slow network conditions#14953
NYCU-Chung wants to merge 2 commits intoremix-run:devfrom
NYCU-Chung:fix/hmr-race-condition

Conversation

@NYCU-Chung
Copy link
Copy Markdown

Summary

Fixes #14906

When the network is slow or edits arrive in rapid succession, HMR updates can be silently dropped or crash the dev server runtime. This PR fixes three race conditions in refresh-utils.mjs:

Race 1: Module not yet imported when enqueueUpdate fires

Vite sends the route manifest update (react-router:hmr event) and the module chunk as separate payloads. On slow networks, the manifest arrives and triggers enqueueUpdate, but window.__reactRouterRouteModuleUpdates.get(route.id) returns undefined because the module chunk hasn't finished downloading.

Before: Throws Error('[react-router:hmr] No module update found for route ...'), permanently breaking HMR for the session.
After: Skips unready routes with continue and retries them on the next cycle.

Race 2: routeUpdates.clear() wipes pending updates

The old code called routeUpdates.clear() and window.__reactRouterRouteModuleUpdates.clear() after processing, which also removed entries added by new edits that arrived during async processing.

Before: New updates silently lost.
After: Only deletes successfully processed route IDs, preserving pending entries.

Race 3: Async-unaware debounce

The debounce wrapper uses setTimeout but enqueueUpdate is async. If a new edit triggers the debounce while the previous enqueueUpdate is still awaiting (e.g., revalidate()), the new timeout can fire concurrently or the first execution's cleanup can race with the second's setup.

Before: Fire-and-forget — no awareness of in-flight execution.
After: Tracks running state; queues a re-execution after the current one completes.

Reproduction

See the reproduction repo linked in #14906: https://github.com/AviVahl/react-router-hmr-issue

The issue reproduces consistently when simulating a slower network in the browser dev tools. With this fix applied, all consecutive HMR updates are correctly applied regardless of network speed.

Changes

  • packages/react-router-dev/vite/static/refresh-utils.mjs: 56 insertions, 26 deletions
    • Async-aware debounce with running/queued flags
    • Graceful skip of routes whose modules haven't loaded yet
    • Partial cleanup (only delete processed route IDs)

Test plan

  • Existing vite-hmr-hdr-test.ts "everything everywhere all at once" test validates concurrent edits still work
  • Manual testing with Chrome DevTools network throttling (Slow 3G) confirms all edits apply
  • Changes are backward-compatible — when timing is normal, behavior is identical to before

@remix-cla-bot
Copy link
Copy Markdown
Contributor

remix-cla-bot Bot commented Apr 7, 2026

Hi @NYCU-Chung,

Welcome, and thank you for contributing to React Router!

Before we consider your pull request, we ask that you sign our Contributor License Agreement (CLA). We require this only once.

You may review the CLA and sign it by adding your name to contributors.yml.

Once the CLA is signed, the CLA Signed label will be added to the pull request.

If you have already signed the CLA and received this response in error, or if you have any questions, please contact us at hello@remix.run.

Thanks!

- The Remix team

@remix-cla-bot
Copy link
Copy Markdown
Contributor

remix-cla-bot Bot commented Apr 7, 2026

Thank you for signing the Contributor License Agreement. Let's get this merged! 🥳

@github-actions
Copy link
Copy Markdown
Contributor

👋 We've moved away from Changesets to our own internal changes process. Please convert your changesets file to a change file in the proper package directory (i.e., packages/react-router/.changes/patch.fix-some-bug.md).

NYCU-Chung and others added 2 commits April 15, 2026 13:37
…conditions

The debounce utility used by enqueueUpdate was not async-safe: if a second
HMR module update arrived while the first was still awaiting revalidation,
the debounce would start a concurrent execution of the async handler.
The first execution had already cleared routeUpdates, so the second
execution found no pending route metadata and silently dropped the update.

Two changes fix this:

1. Make debounce async-aware — if the wrapped function is already running,
   queue the call and replay it once the current run finishes (with the
   same delay). This prevents concurrent executions of the async handler.

2. Skip (instead of throw) routes whose module has not loaded yet.
   Under slow networks the react-router:hmr custom event (route metadata)
   arrives via WebSocket before the module finishes loading over HTTP.
   The old code threw when it encountered a route without a matching
   module. Now it skips that route and only clears entries that were
   actually processed, so unprocessed routes are picked up on the next
   debounced call.

Together these changes ensure every HMR update is eventually applied
regardless of network latency or rapid successive edits.

Fixes remix-run#14906

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@NYCU-Chung NYCU-Chung force-pushed the fix/hmr-race-condition branch from f82caa7 to 56754c3 Compare April 15, 2026 05:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants