Skip to content

fix(vite): don't send browser full-reload for ssr-only changes#4034

Merged
pi0 merged 4 commits intomainfrom
no-browser-reload-hmr
Feb 23, 2026
Merged

fix(vite): don't send browser full-reload for ssr-only changes#4034
pi0 merged 4 commits intomainfrom
no-browser-reload-hmr

Conversation

@schiller-manuel
Copy link
Contributor

@schiller-manuel schiller-manuel commented Feb 13, 2026

hotUpdate hook was unconditionally sending server.ws.send full-reload when any server-only module was detected, destroying client-side HMR. Split modules into server-only vs shared, only browser-reload when ALL modules are server-only and serverReload is explicitly enabled (default changed from true to false).

closes TanStack/router#5904

@schiller-manuel schiller-manuel requested a review from pi0 as a code owner February 13, 2026 21:41
@vercel
Copy link

vercel bot commented Feb 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nitro.build Ready Ready Preview, Comment Feb 23, 2026 9:32am

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The changes refine HMR behavior by distinguishing between server-only and shared modules, enabling granular reload control to prevent unnecessary full-page reloads when only client code changes. Documentation is updated, and comprehensive test fixtures validate the new behavior.

Changes

Cohort / File(s) Summary
Core Plugin Logic
src/build/vite/plugin.ts, src/build/vite/types.ts
Modified nitroMain hotUpdate handler to classify modules as server-only or shared, trigger full reloads only for server-only modules, and send WebSocket reload signals when appropriate. Updated serverReload property documentation to reflect invalidation and conditional reload behavior.
Test Fixture Setup
test/vite/hmr-fixture/shared.ts, test/vite/hmr-fixture/api/state.ts, test/vite/hmr-fixture/app/entry-client.ts, test/vite/hmr-fixture/app/entry-server.ts, test/vite/hmr-fixture/tsconfig.json, test/vite/hmr-fixture/vite.config.ts
Added complete HMR test fixture with shared state module, API endpoint, client entry point with DOM updates, server entry point with HTML rendering, TypeScript configuration, and Vite configuration with Nitro plugin.
HMR Test Suite
test/vite/hmr.test.ts
Added comprehensive test suite validating HMR behavior across editing API entries, client entries, server entries, and shared entries; includes utilities for file manipulation, response polling, and WebSocket message capture.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description accurately explains the problem (hotUpdate unconditionally sending full-reload), the solution (splitting modules into server-only vs shared), and references the related issue.
Linked Issues check ✅ Passed The PR successfully addresses issue #5904 by modifying the hotUpdate hook to preserve client-side HMR when server-only modules are updated alongside shared modules, and adds comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing HMR behavior: core plugin logic, documentation updates, test fixtures, and test cases validating the fix for issue #5904.
Title check ✅ Passed The title follows conventional commits format with a 'fix' type prefix and clearly describes the main change: preventing unnecessary browser full-reloads for server-only module changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch no-browser-reload-hmr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 13, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nitrojs/nitro@4034

commit: c339b9d

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/build/vite/plugin.ts`:
- Around line 262-268: The conditional uses
ctx.pluginConfig.experimental?.vite.serverReload which can throw if experimental
exists but vite is undefined; update the check to safely optional-chain through
vite (e.g., ctx.pluginConfig.experimental?.vite?.serverReload) so access to
serverReload is guarded; locate the block around serverOnlyModules/sharedModules
and change the experimental?.vite.serverReload usage in that if-statement to use
?.vite?.serverReload.

hotUpdate hook now separates server-only vs shared modules.
Browser full-reload only sent when serverReload option is true (default false).
Shared modules continue through normal HMR pipeline.
Adds e2e tests for both default (no reload) and serverReload:true behaviors.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/build/vite/types.ts (1)

33-38: ⚠️ Potential issue | 🟡 Minor

@default true is inconsistent with the PR's stated intent.

The implementation uses === false as the guard (Line 244 of plugin.ts), so undefined/unset behaves the same as true. If the intent is that browser full-reloads are opt-in (default false), the guard in plugin.ts should be !== true (or the server.ws.send call should be additionally gated on serverReload === true). If the current behavior is intentional, the JSDoc description should clarify what exactly serverReload: false suppresses versus the browser-reload-only suppression described in the PR. As per coding guidelines, update types and JSDoc for API changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/build/vite/types.ts` around lines 33 - 38, The JSDoc for serverReload is
inconsistent with the implementation: update the API so the default is opt-in
(browser reloads disabled unless explicitly enabled) by changing the JSDoc in
types.ts to "@default false" and then adjust the runtime guard in plugin.ts (the
check around serverReload at the current guard) to treat only true as enabling
reloads (use a !== true check or gate the server.ws.send on serverReload ===
true) so undefined behaves as false; reference the serverReload property and the
guard in plugin.ts when making the changes.
♻️ Duplicate comments (1)
test/vite/hmr.test.ts (1)

71-75: ⚠️ Potential issue | 🟠 Major

"Editing client entry" test is a no-op due to fixture/test mismatch.

content.replace(\+ ""`, `+ " (modified)"`)searches for+ "", which does not exist in the current entry-client.tsfixture (it already contains+ " (modified)"). The replacesilently returns the original string,writeFileSyncwrites unchanged content, andpollResponseimmediately matches because the pre-existing text satisfies the regex. The test provides no coverage of the no-browser-reload HMR path. This resolves automatically onceentry-client.tsis corrected to use+ ""` as the initial state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/vite/hmr.test.ts` around lines 71 - 75, The "Editing client entry (no
full-reload)" test is currently a no-op because files.client.update in the test
replaces `+ ""` which isn't present in the entry-client.ts fixture; update the
fixture so the initial entry-client.ts contains `+ ""` (the original unmodified
text) so files.client.update(...) actually changes the file, thereby exercising
the no-full-reload HMR path in the test; change the entry-client.ts fixture (the
file used by the tests) to restore `+ ""` as the starting content so the test's
replace call and the pollResponse(`${serverURL}/app/entry-client.ts`,
/modified/) assertion become meaningful.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/vite/hmr-fixture/api/state.ts`:
- Around line 1-3: The current named JSON import causes TS1544 under NodeNext;
replace the named import with a default JSON import so TypeScript can compile:
change the import statement that references "state" (import { state } from
"../shared.json" with { type: "json" };) to a default import (e.g., import state
from "../shared.json" assert { type: "json" };) and keep the existing export
default () => ({ state }); unchanged so the module uses the default-imported
state value.

In `@test/vite/hmr-fixture/app/entry-client.ts`:
- Around line 1-3: Replace the namespace JSON import with a default import and
make the client-entry initially include + "" so the HMR test's string-replace
actually mutates the file: change the import to use default import from
"../shared.json" (so shared.state is a valid property) and update the
document.getElementById("client-state-value")!.textContent assignment to use
shared.state + "" (so the test can replace + "" with + " (modified)" and trigger
the intended HMR behavior).

In `@test/vite/hmr-fixture/app/entry-server.ts`:
- Line 7: The code assigns apiData from serverFetch(...).then((res) =>
res.json()) where Response.json() is typed unknown; update the call to provide a
concrete type so you can access .state safely — e.g., await
serverFetch("/api/state").then((res) => res.json() as { state: YourStateType })
or add a generic: serverFetch("/api/state").then((res) => res.json<{ state:
YourStateType }>()), and apply the same change to the other occurrence so
accesses to apiData.state type-check; reference the apiData variable and the
serverFetch(...).then((res) => res.json()) expression to locate the fixes.
- Line 3: The namespace JSON import in entry-server.ts (the `import * as shared
...` line) prevents accessing shared.state at compile time; change the import to
a default import of the JSON module (so `shared` is the module value rather than
a namespace) to restore access to shared.state and fix static analysis — update
the same pattern in entry-client.ts as well where `import * as shared` appears.

---

Outside diff comments:
In `@src/build/vite/types.ts`:
- Around line 33-38: The JSDoc for serverReload is inconsistent with the
implementation: update the API so the default is opt-in (browser reloads
disabled unless explicitly enabled) by changing the JSDoc in types.ts to
"@default false" and then adjust the runtime guard in plugin.ts (the check
around serverReload at the current guard) to treat only true as enabling reloads
(use a !== true check or gate the server.ws.send on serverReload === true) so
undefined behaves as false; reference the serverReload property and the guard in
plugin.ts when making the changes.

---

Duplicate comments:
In `@test/vite/hmr.test.ts`:
- Around line 71-75: The "Editing client entry (no full-reload)" test is
currently a no-op because files.client.update in the test replaces `+ ""` which
isn't present in the entry-client.ts fixture; update the fixture so the initial
entry-client.ts contains `+ ""` (the original unmodified text) so
files.client.update(...) actually changes the file, thereby exercising the
no-full-reload HMR path in the test; change the entry-client.ts fixture (the
file used by the tests) to restore `+ ""` as the starting content so the test's
replace call and the pollResponse(`${serverURL}/app/entry-client.ts`,
/modified/) assertion become meaningful.
ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f545c4d and 10f70d5.

📒 Files selected for processing (9)
  • src/build/vite/plugin.ts
  • src/build/vite/types.ts
  • test/vite/hmr-fixture/api/state.ts
  • test/vite/hmr-fixture/app/entry-client.ts
  • test/vite/hmr-fixture/app/entry-server.ts
  • test/vite/hmr-fixture/shared.json
  • test/vite/hmr-fixture/tsconfig.json
  • test/vite/hmr-fixture/vite.config.ts
  • test/vite/hmr.test.ts
✅ Files skipped from review due to trivial changes (1)
  • test/vite/hmr-fixture/shared.json

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
test/vite/hmr-fixture/app/entry-client.ts (1)

3-3: ⚠️ Potential issue | 🔴 Critical

The HMR client-edit test is a no-op — initial fixture content must contain + "".

The test at hmr.test.ts line 72 does content.replace(\+ ""`, `+ " (modified)"`), but this file already contains + " (modified)". The replace` matches nothing, the file is never written, and the "Editing client entry (no full-reload)" test passes vacuously without exercising HMR.

🐛 Proposed fix
-document.getElementById("client-state-value")!.textContent = state + " (modified)";
+document.getElementById("client-state-value")!.textContent = state + "";

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/vite/hmr-fixture/app/entry-client.ts` at line 3, The fixture's client
entry already contains the modified suffix, so the HMR test's replace call
(which looks for the literal + "") never matches; update the initial expression
in entry-client.ts—specifically the assignment to
document.getElementById("client-state-value")!.textContent that currently uses
state + " (modified)"—to use state + "" so the test can perform its replace to +
" (modified)" and actually exercise the HMR path.
🧹 Nitpick comments (1)
test/vite/hmr.test.ts (1)

136-153: pollResponse aborts on the first transient fetch error.

A single failed fetch (e.g. server momentarily restarting after a file edit) rejects the entire promise instead of retrying. Consider retrying on network errors within the timeout window, similar to how timeout is handled on Line 142.

♻️ Suggested improvement
       } catch (err) {
-        reject(err);
+        if (Date.now() - start > timeout) {
+          reject(err);
+        } else {
+          setTimeout(check, 100);
+        }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/vite/hmr.test.ts` around lines 136 - 153, The helper currently rejects
the whole poll on the first fetch error inside the inner async function check;
change the catch block in check (used by pollResponse) to treat transient
network/fetch failures as retryable: instead of immediate reject(err), if
Date.now() - start > timeout then reject(err), otherwise wait (setTimeout(check,
100)) and continue polling; reference the async function check, variables url,
match, lastResponse, timeout and start when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/vite/hmr.test.ts`:
- Around line 71-75: The test "Editing client entry (no full-reload)" is a no-op
because files.client.update(...replace(`+ ""`, ...)) doesn't match the fixture;
either update the fixture entry-client.ts to contain state + "" so the replace
will produce state + " (modified)", or change the test's replace target to match
the current fixture (e.g. replace(`state + " (modified)"`, `state + " (modified)
(edited)"`) or similar) so a real file write occurs; after that, keep using
pollResponse(`${serverURL}/app/entry-client.ts`, /modified/) but confirm that
polling the raw module URL detects the client-side change in your Vite setup
(adjust to the appropriate served URL if Vite serves a transformed path).

---

Duplicate comments:
In `@test/vite/hmr-fixture/app/entry-client.ts`:
- Line 3: The fixture's client entry already contains the modified suffix, so
the HMR test's replace call (which looks for the literal + "") never matches;
update the initial expression in entry-client.ts—specifically the assignment to
document.getElementById("client-state-value")!.textContent that currently uses
state + " (modified)"—to use state + "" so the test can perform its replace to +
" (modified)" and actually exercise the HMR path.

---

Nitpick comments:
In `@test/vite/hmr.test.ts`:
- Around line 136-153: The helper currently rejects the whole poll on the first
fetch error inside the inner async function check; change the catch block in
check (used by pollResponse) to treat transient network/fetch failures as
retryable: instead of immediate reject(err), if Date.now() - start > timeout
then reject(err), otherwise wait (setTimeout(check, 100)) and continue polling;
reference the async function check, variables url, match, lastResponse, timeout
and start when making the change.
ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 10f70d5 and c339b9d.

📒 Files selected for processing (5)
  • test/vite/hmr-fixture/api/state.ts
  • test/vite/hmr-fixture/app/entry-client.ts
  • test/vite/hmr-fixture/app/entry-server.ts
  • test/vite/hmr-fixture/shared.ts
  • test/vite/hmr.test.ts

@pi0 pi0 changed the title fix: don't send browser full-reload for server-only modules by default fix(vite): don't send browser full-reload for ssr-only changes Feb 23, 2026
@pi0 pi0 merged commit 1daa177 into main Feb 23, 2026
12 checks passed
@pi0 pi0 deleted the no-browser-reload-hmr branch February 23, 2026 10:02
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.

HMR is broken in route with server function using nitro v3 plugin

2 participants