Skip to content

fix: client HMR dev overlay recovery - #1758

Merged
james-elicx merged 3 commits into
cloudflare:mainfrom
hyoban:codex/dev-overlay-client-recovery
Jun 5, 2026
Merged

fix: client HMR dev overlay recovery#1758
james-elicx merged 3 commits into
cloudflare:mainfrom
hyoban:codex/dev-overlay-client-recovery

Conversation

@hyoban

@hyoban hyoban commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Follow #1746

@pkg-pr-new

pkg-pr-new Bot commented Jun 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1758

commit: be471a0

@hyoban
hyoban marked this pull request as ready for review June 5, 2026 13:35
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: client HMR dev overlay recovery

Nice, focused follow-up to #1746. #1746 covered server/RSC HMR recovery; this extends recovery to client component Fast Refresh, which takes a different code path. I verified the design and behavior:

  • __registerBeforePerformReactRefresh is a real hook exposed by @vitejs/plugin-react's refresh runtime (node_modules/.../refresh-runtime.js:599). The registered callbacks are awaited before performReactRefresh() (line 602-605), so dispatching the recovery event there correctly resets error boundaries before the refreshed modules re-render. Good choice of seam.
  • Recovery coverage is complete: generic client render errors are only caught by ErrorBoundaryInner (when error.tsx exists) or the catch-all DevRecoveryBoundary. Both got the listener. The other boundaries (RedirectErrorBoundary, NotFoundBoundary, Forbidden, Unauthorized) only catch navigation-signal errors, so they correctly don't need it.
  • The defensive retry scheduling (queueMicrotask + setTimeout fallback) is reasonable given the runtime preamble normally loads before app code, so the first synchronous tryInstall will almost always succeed.
  • The dismissOverlay() added to reportViteHmrError (and in the refresh callback) correctly prevents stale runtime errors from lingering in overlay pagination when a build error or new runtime error supersedes them. The three new e2e tests cover toggle, runtime-replace, and build-error-replace cases.

Verification:

  • vp check on all 4 changed source/test files: pass (format, lint, type)
  • vp test run tests/app-browser-entry.test.ts -t "React Refresh recovery": 2 passed

Only minor, non-blocking observations below. LGTM.

if (typeof register !== "function") return false;

refreshWindow.__vinextReactRefreshErrorRecoveryInstalled = true;
register(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (non-blocking): this callback runs dismissOverlay() on every Fast Refresh, including refreshes with no active error. dismissOverlay() is not idempotent — it always reassigns the snapshot and calls emit() (dev-error-overlay-store.ts:102-105), notifying subscribers even when the overlay is already empty. In practice each refresh is debounced at 16ms so the extra emit is negligible, but you could guard it with a snapshot check (e.g. only dismiss when there are errors) if you want to avoid the redundant store notification. Not required.

void Promise.resolve().then(retry);
}
window.setTimeout(retry, 0);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (non-blocking): if both the microtask and the setTimeout(0) retry fire before the refresh runtime has set __registerBeforePerformReactRefresh, retry() resets __vinextReactRefreshErrorRecoveryInstallScheduled = false and no further attempt is scheduled. Since installReactRefreshErrorRecovery() is only called once via installDevErrorOverlay() at startup, recovery would silently never install in that (unlikely) race. The refresh preamble is normally injected before app code so the first synchronous tryInstall succeeds, making this purely theoretical — just noting it in case the install site ever changes.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of my findings:

Review complete — PR #1758 LGTM

This is a clean, focused follow-up to #1746 that extends dev overlay recovery from server/RSC HMR to client component Fast Refresh.

What I verified:

  • Correct seam: __registerBeforePerformReactRefresh is a genuine hook from @vitejs/plugin-react's refresh runtime, and its callbacks are awaited before performReactRefresh(). Dispatching the recovery event there resets error boundaries before refreshed modules re-render — exactly right.
  • Complete boundary coverage: Generic client render errors are only caught by ErrorBoundaryInner and the catch-all DevRecoveryBoundary; both got the listener. The navigation-signal-only boundaries correctly don't need it.
  • Stale error fix: dismissOverlay() in reportViteHmrError and the refresh callback prevents stale runtime errors from lingering in overlay pagination. The 3 new e2e tests cover toggle, runtime-replace, and build-error-replace.
  • Checks pass: vp check (format/lint/type) on all changed files, and the 2 new unit tests pass.

Two minor, non-blocking notes posted inline:

  1. dismissOverlay() fires on every Fast Refresh and isn't idempotent (redundant store emit when overlay is already empty) — negligible due to 16ms debounce.
  2. A theoretical race where recovery never installs if both retry attempts fire before the refresh runtime loads — purely hypothetical given the preamble normally loads first.

Neither is a blocker.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: client HMR dev overlay recovery

Verified independently — this is a clean, well-tested follow-up to #1746 that extends overlay recovery to client-component Fast Refresh. Agree with the prior review: LGTM.

What I confirmed:

  • __registerBeforePerformReactRefresh is the right seam — its callbacks are awaited before performReactRefresh(), so resetting the error boundaries before refreshed modules re-render is correct.
  • Recovery listeners land on the two boundaries that catch generic client render errors: ErrorBoundaryInner (production error.tsx wrapper) and the dev-only DevRecoveryBoundary. Navigation-signal-only boundaries correctly don't get it.
  • The new unit tests pass locally (vp test run tests/app-browser-entry.test.ts -t "React Refresh recovery" → 2 passed). The 3 e2e scenarios (toggle, runtime-replace, build-error-replace) cover the meaningful paths.
  • The dev recovery listener in ErrorBoundaryInner is unconditional (no import.meta.env.DEV guard), but it's harmless in production since the event is never dispatched outside the dev overlay. Negligible per-boundary addEventListener cost.

Three minor, non-blocking notes inline. None block merge.

// Vite build errors describe the current HMR update. Replace any previous
// runtime/HMR failure so stale errors from earlier edits do not remain in the
// overlay pagination.
dismissOverlay();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this unconditional dismissOverlay() changes existing Vite-HMR behavior — every vite:error now clears the entire overlay before reporting the new build error, not just superseding runtime errors. In practice Vite emits one error per failed update so this matches the intended "build error replaces stale runtime error" behavior (and the new e2e test covers it). Just flagging that any previously-stacked entries (including other Vite errors) are dropped too, which is the desired outcome here but worth a one-line note in the comment if you want to be explicit.

} else {
void Promise.resolve().then(retry);
}
window.setTimeout(retry, 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (same as noted previously): if both the microtask and setTimeout(0) retries fire before the refresh runtime sets __registerBeforePerformReactRefresh, retry() clears __vinextReactRefreshErrorRecoveryInstallScheduled and nothing reschedules — recovery would silently never install. The preamble normally loads first so the synchronous tryInstall succeeds, making this purely theoretical. A small hardening would be to keep retrying (e.g. re-arm via another setTimeout) instead of giving up after the second attempt.

refreshWindow.__vinextReactRefreshErrorRecoveryInstalled = true;
register(() => {
window.dispatchEvent(new Event(VINEXT_DEV_ERROR_RECOVERY_EVENT));
dismissOverlay();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this callback runs dismissOverlay() on every Fast Refresh, even when no error is active. dismissOverlay() always reassigns the snapshot and calls emit() (dev-error-overlay-store.ts:102-105), so subscribers get a redundant notification when the overlay is already empty. Negligible given the 16ms debounce, but you could guard with a snapshot check if you want to avoid the no-op emit.

callbacks.push(cb);
},
dispatchEvent,
setTimeout: vi.fn(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: stubbing setTimeout: vi.fn() plus providing __registerBeforePerformReactRefresh up front means the synchronous tryInstall always succeeds, so the deferred retry path (microtask / setTimeout fallback) is never exercised by these tests. Consider adding a case where __registerBeforePerformReactRefresh is initially absent then set, invoking the captured retry, to cover the scheduling branch.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Review complete — PR #1758 LGTM

I independently verified this follow-up to #1746, which extends dev-overlay recovery from server/RSC HMR to client-component Fast Refresh.

Verified:

  • Correct seam: __registerBeforePerformReactRefresh callbacks are awaited before performReactRefresh(), so resetting error boundaries before refreshed modules re-render is right.
  • Boundary coverage: Listeners are added to ErrorBoundaryInner (prod error.tsx wrapper) and the dev-only DevRecoveryBoundary — the two boundaries that catch generic client render errors. Navigation-signal-only boundaries correctly excluded.
  • Tests pass: ran vp test run tests/app-browser-entry.test.ts -t "React Refresh recovery" → 2 passed. Three e2e scenarios cover toggle, runtime-replace, and build-error-replace.
  • Prod safety: the unconditional listener in ErrorBoundaryInner is a harmless no-op in production (event never dispatched there).

4 minor, non-blocking notes posted inline:

  1. The new unconditional dismissOverlay() in reportViteHmrError changes Vite-HMR behavior — each build error now clears the whole overlay. Intentional and tested, but worth documenting.
  2. Theoretical retry-install race (same as prior review).
  3. dismissOverlay() fires a redundant store emit on every Fast Refresh (non-idempotent).
  4. The unit tests stub setTimeout and provide the hook up front, so the deferred retry-scheduling branch is never exercised — suggested adding a case for it.

None of these block merge.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk re-review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: client HMR dev overlay recovery — LGTM

Independently verified this follow-up to #1746. It extends dev-overlay recovery from server/RSC HMR to client-component Fast Refresh, and it has addressed the minor points raised in the earlier /bigbonk reviews.

What I confirmed:

  • Correct seam. __registerBeforePerformReactRefresh is a real hook from @vitejs/plugin-react's refresh runtime, and its callbacks are awaited before performReactRefresh(). Dispatching vinext:dev-error-recovery there resets the error boundaries before refreshed modules re-render. Right choice.
  • Boundary coverage is complete. The listener lands on the two boundaries that catch generic client render errors — ErrorBoundaryInner (error-boundary.tsx:220) and the dev-only DevRecoveryBoundary (error-boundary.tsx:550). Navigation-signal-only boundaries (NotFound/Redirect/Forbidden/Unauthorized) correctly don't get it. Both handlers no-op when state.error is null, and both clean up in componentWillUnmount.
  • Idempotency note from prior reviews is now fixed. dismissOverlay() got an early-return guard (dev-error-overlay-store.ts:103), so the redundant store emit on no-op refreshes is gone, with a dedicated unit test ("does not notify subscribers when an empty overlay is dismissed").
  • Retry-install "race" from prior reviews does not actually exist. I traced installReactRefreshErrorRecovery (dev-error-overlay.tsx:98-130): on failure, retry() always re-arms scheduleTimeoutRetry(16) and only clears __vinextReactRefreshErrorRecoveryInstallScheduled on success — so it keeps retrying indefinitely rather than giving up after the second attempt. The new "retries until the React Refresh runtime hook is available" test exercises this branch directly.
  • Prod safety. The unconditional addEventListener in ErrorBoundaryInner is a harmless no-op in production since the event is never dispatched outside the dev overlay.
  • Build-error replacement. The new dismissOverlay() in reportViteHmrError (dev-error-overlay.tsx:219) is documented with a comment and covered by the build-error e2e test.

Verification run locally:

  • vp check on all 4 changed source files → pass (format, lint, type)
  • vp test run tests/app-browser-entry.test.ts -t "React Refresh recovery" → 3 passed
  • vp test run tests/app-browser-entry.test.ts -t "dev overlay store" → 1 passed

No blocking issues. One trivial optional nit inline.

scheduleTimeoutRetry(REACT_REFRESH_RECOVERY_RETRY_DELAY_MS);
}

if (typeof queueMicrotask === "function") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional / trivial: scheduleTimeoutRetry(0) already schedules a retry on the next macrotask, so the queueMicrotask path is a small head-start optimization rather than load-bearing. The fallback void Promise.resolve().then(retry) for environments without queueMicrotask is dead in practice (any runtime with window.setTimeout has queueMicrotask), but it's harmless. No change needed.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Re-review complete — PR #1758 LGTM

I independently re-reviewed this follow-up to #1746, which extends dev-overlay recovery from server/RSC HMR to client-component Fast Refresh.

Verified:

  • Correct seam__registerBeforePerformReactRefresh callbacks are awaited before performReactRefresh(), so resetting boundaries first is right.
  • Complete boundary coverage — listener lands only on ErrorBoundaryInner and DevRecoveryBoundary (the two boundaries catching generic client render errors); both no-op when not errored and clean up on unmount.
  • Prior nits already addressed in this PR:
    • The non-idempotent dismissOverlay() got an early-return guard (dev-error-overlay-store.ts:103) plus a dedicated unit test.
    • A new retry-branch unit test was added.
  • Corrected the record on the "retry-install race" flagged in earlier /bigbonk reviews: tracing installReactRefreshErrorRecovery shows retry() always re-arms setTimeout(16) on failure and only clears the scheduled flag on success — it retries indefinitely, so it does not silently give up. That concern doesn't actually exist.

Local checks: vp check (4 files) pass; React Refresh recovery (3) and dev overlay store (1) tests pass.

One trivial optional inline nit (dead Promise.resolve fallback for the no-queueMicrotask case). No blockers.

github run

@james-elicx
james-elicx merged commit 06f37e9 into cloudflare:main Jun 5, 2026
29 checks passed
@github-actions github-actions Bot mentioned this pull request Jun 5, 2026
@james-elicx

Copy link
Copy Markdown
Member

fyi @hyoban, the new overlay tests are appearing to be rather flaky - https://github.com/cloudflare/vinext/actions/runs/27077153897/job/79916376566

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants