Skip to content

fix(rsc): keep client HMR for client modules co-located with rsc-graph code#1248

Open
sreetamdas wants to merge 4 commits into
vitejs:mainfrom
sreetamdas:fix/rsc-co-located-client-hmr
Open

fix(rsc): keep client HMR for client modules co-located with rsc-graph code#1248
sreetamdas wants to merge 4 commits into
vitejs:mainfrom
sreetamdas:fix/rsc-co-located-client-hmr

Conversation

@sreetamdas

@sreetamdas sreetamdas commented Jun 12, 2026

Copy link
Copy Markdown

Description

Problem

In dev, editing a route/page component does not Fast Refresh when that component's file also contains server‑graph code (e.g. a co‑located server function). On save, only an (rsc) HMR update fires; the client environment receives nothing, so the DOM never updates and there is no full reload either.

Root cause

In the client environment, hotUpdate returns [] for any file that is present in the rsc module graph and not inside a "use client" boundary:

if (this.environment.name === 'client') {
  const mod = ctx.server.environments.rsc!.moduleGraph.getModuleById(ctx.file)
  if (mod) {
    // reload CSS importers…
    return [] // suppresses client HMR
  }
}

Per the comment, this guard exists to avoid full reloads when server‑only files leak into the client graph as style/watch deps (Tailwind / addWatchFile). But the discriminator — "in the rsc graph and not a "use client" boundary" — also matches a genuine client module that merely co‑resides with rsc‑graph code.

Concretely: a framework route file (e.g. TanStack Start) that co‑locates a createServerFn with a client‑rendered route component ends up in the rsc graph (via the server‑fn split), while the route component is a real client module imported by the client route tree — but it is not a "use client" reference, so isInsideClientBoundary is false. The guard then suppresses the route component's client HMR, and editing its JSX does nothing in dev.

Fix

Only return [] when the file has no non‑CSS client importers — i.e. it really is present only as a style/watch dep. This mirrors the discriminator the rsc‑branch already uses (importers.every(isCSSRequest)). Files with genuine non‑CSS client importers (the route component, imported by the route tree) keep their client HMR / Fast Refresh.

Reproduction

Minimal standalone repro (clone → pnpm install && pnpm dev → edit the route component → no update):
https://github.com/sreetamdas/tanstack-rsc-hmr-co-located-serverfn-repro

It's a TanStack Start RSC app with one route whose file contains both a createServerFn and the route component. Moving the server fn into a separate file restores HMR. Also reproduced in TanStack's own e2e/react-start/rsc (TanStack/router#7621 — a canary that fails today and passes with this fix), and confirmed on a production app (plugin-rsc@0.5.27).

Open questions (this is a draft — starting the conversation before polishing)

  • Tests: the trigger requires a client‑side router that imports route components without a "use client" boundary — a pattern not present in this repo's examples (the react-router example uses "use client" for client routes and splits client/server across files). Happy to add an upstream test wherever you'd prefer (a dedicated example, or extending an existing one) — guidance on the shape you'd accept would help.
  • Scope: the comment on this branch notes it's "not necessary since Vite 7.1.0-beta.0 (#20391)", and the repo now requires Vite ≥ 8 (refactor(react)!: drop Vite 7 and below support #1124). Would you prefer this targeted refinement, or removing the client branch entirely given the minimum Vite version? Happy to do either.

Thanks!

…h code

The client hotUpdate branch returns [] for any file present in the rsc module
graph that is not inside a "use client" boundary, to avoid full reloads from
server-only files pulled into the client graph as style deps (tailwind /
addWatchFile). This also suppresses HMR for genuine client modules that happen
to co-reside with rsc-graph code.

A framework route file (e.g. TanStack Start) that co-locates a createServerFn
with a client-rendered route component is in the rsc graph, yet the route
component is a real client module (imported by the client route tree), so its
Fast Refresh is wrongly suppressed and edits never reach the browser.

Only suppress when the file has no non-CSS client importers, mirroring the
existing rsc-branch check (importers.every(isCSSRequest)).
@sreetamdas sreetamdas marked this pull request as ready for review June 12, 2026 18:01

@hi-ogawa hi-ogawa 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.

Can you add regression test in e2e?

@sreetamdas

Copy link
Copy Markdown
Author

Added in e2e/co-located-client-hmr.test.ts plus a minimal examples/co-located-client-hmr.

Triggering this outside a framework needed a specific setup. The guard only returns [] when isInsideClientBoundary is false on the client env — i.e. the edited module reaches the client entry with no "use client" anywhere in the chain. The existing examples always go through a "use client" boundary, so none of them hit it. This one does what a client router does:

  • entry.browserapp.tsxroutes/page.tsx (Page) — no "use client" in the chain
  • server root.tsx imports ServerNote from the same page.tsx, so the file is also in the rsc graph

Editing the component then asserts Fast Refresh with state preserved. Fails on main (the edit is dropped on the client), passes with the fix.

@hi-ogawa hi-ogawa self-assigned this Jun 13, 2026
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