diff --git a/packages/plugin-rsc/e2e/css-link-precedence.test.ts b/packages/plugin-rsc/e2e/css-link-precedence.test.ts
new file mode 100644
index 000000000..69751521e
--- /dev/null
+++ b/packages/plugin-rsc/e2e/css-link-precedence.test.ts
@@ -0,0 +1,60 @@
+import { expect, test } from '@playwright/test'
+import { setupInlineFixture, useFixture } from './fixture'
+import { expectNoReload, waitForHydration } from './helper'
+import { defineStarterTest } from './starter'
+
+test.describe('cssLinkPrecedence-false', () => {
+ const root = 'examples/e2e/temp/cssLinkPrecedence-false'
+
+ test.beforeAll(async () => {
+ await setupInlineFixture({
+ src: 'examples/starter',
+ dest: root,
+ files: {
+ 'vite.config.base.ts': { cp: 'vite.config.ts' },
+ 'vite.config.ts': /* js */ `
+ import { defineConfig, mergeConfig } from 'vite'
+ import baseConfig from './vite.config.base.ts'
+
+ const overrideConfig = defineConfig({
+ rsc: {
+ cssLinkPrecedence: false,
+ },
+ })
+
+ export default mergeConfig(baseConfig, overrideConfig)
+ `,
+ },
+ })
+ })
+
+ test.describe('dev', () => {
+ const f = useFixture({ root, mode: 'dev' })
+ defineStarterTest(f)
+
+ // TODO: move css hmr test to `starter.ts`
+ test('css hmr', async ({ page }) => {
+ await page.goto(f.url())
+ await waitForHydration(page)
+ const card = page.locator('.card').nth(0)
+
+ await using _ = await expectNoReload(page)
+ const editor = f.createEditor('src/index.css')
+ editor.edit((s) =>
+ s.replace(
+ '.card {\n padding: 1rem;',
+ `.card {\n padding: 1rem; background-color: rgb(255, 0, 200);`,
+ ),
+ )
+ await expect(card).toHaveCSS('background-color', 'rgb(255, 0, 200)')
+
+ editor.reset()
+ await expect(card).not.toHaveCSS('background-color', 'rgb(255, 0, 200)')
+ })
+ })
+
+ test.describe('build', () => {
+ const f = useFixture({ root, mode: 'build' })
+ defineStarterTest(f)
+ })
+})
diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts
index ba546297a..5cbe58502 100644
--- a/packages/plugin-rsc/src/plugin.ts
+++ b/packages/plugin-rsc/src/plugin.ts
@@ -722,7 +722,20 @@ export default function vitePluginRsc(
async hotUpdate(ctx) {
if (isCSSRequest(ctx.file)) {
if (this.environment.name === 'client') {
- return
+ const cssLinkPrecedence = rscPluginOptions.cssLinkPrecedence ?? true
+ if (cssLinkPrecedence) return
+
+ // Without stylesheet precedence, React owns swapping stylesheet link
+ // for server css hmr by reconciling new link with ,
+ // so we filter out `css` type to prevent triggering Vite's `css-update` hmr,
+ // which tries to swap the same link.
+ // we keep `js` type hmr to trigger hmr for css side effect import on client environment
+ // (though probably css imported both client and server don't behave well.)
+ const rscMod =
+ ctx.server.environments.rsc?.moduleGraph.getModuleById(ctx.file)
+ if (rscMod) {
+ return ctx.modules.filter((mod) => mod.type !== 'css')
+ }
}
}
@@ -2229,7 +2242,7 @@ function vitePluginRscCss(
const visitedFiles = new Set()
function recurse(id: string) {
- if (visited.has(id)) {
+ if (visited.has(id) || parseCssVirtual(id)) {
return
}
visited.add(id)
@@ -2240,6 +2253,9 @@ function vitePluginRscCss(
for (const next of mod?.importedModules ?? []) {
if (next.id) {
if (isCSSRequest(next.id)) {
+ if (next.file) {
+ visitedFiles.add(next.file)
+ }
if (hasSpecialCssQuery(next.id)) {
continue
}
@@ -2253,9 +2269,32 @@ function vitePluginRscCss(
recurse(entryId)
- // this doesn't include ?t= query so that RSC won't keep adding styles.
+ // CSS links emitted from RSC participate in two different HMR strategies.
+ //
+ // cssLinkPrecedence: true (default)
+ // React treats as a stylesheet resource. Keep the
+ // RSC-rendered href stable here and let Vite's normal client `css-update`
+ // handle edits for the `.css?direct` module. Vite finds the existing
+ // stylesheet link by pathname, clones it, rewrites the clone to
+ // `?t=`, then removes the previous link after the new one
+ // loads. https://github.com/vitejs/vite/blob/a19003516951a3710aab0f2646d78c48b2e5d2ad/packages/vite/src/client/client.ts#L234-L235
+ // React does not re-insert that original stable-href resource on
+ // later RSC renders, so the Vite-owned timestamped link remains the live
+ // stylesheet. This is why RSC must not inject its own timestamp in
+ // precedence mode: doing so makes React see every edit as a new resource
+ // and append more stylesheet links.
+ //
+ // cssLinkPrecedence: false
+ // React reconciles like a normal DOM element. In this mode RSC owns
+ // the link swap: on CSS HMR, the RSC refetch re-renders this resource list
+ // with `?t=`.
+ // In this mode, we prevent Vite from swapping the same style
+ // by filtering out `css-update` hmr in our `rsc` plugin's `hotUpdate` hook above.
+ const cssLinkPrecedence = rscCssOptions?.cssLinkPrecedence ?? true
const hrefs = [...cssIds].map((id) =>
- normalizeViteImportAnalysisUrl(environment, id),
+ normalizeViteImportAnalysisUrl(environment, id, {
+ injectHMRTimestamp: !cssLinkPrecedence,
+ }),
)
return { ids: [...cssIds], hrefs, visitedFiles: [...visitedFiles] }
}
diff --git a/packages/plugin-rsc/src/plugins/vite-utils.ts b/packages/plugin-rsc/src/plugins/vite-utils.ts
index 511785658..1ebe38372 100644
--- a/packages/plugin-rsc/src/plugins/vite-utils.ts
+++ b/packages/plugin-rsc/src/plugins/vite-utils.ts
@@ -113,11 +113,12 @@ export function normalizeResolvedIdToUrl(
export function normalizeViteImportAnalysisUrl(
environment: DevEnvironment,
id: string,
+ options?: { injectHMRTimestamp?: boolean },
): string {
let url = normalizeResolvedIdToUrl(environment, id, { id })
// https://github.com/vitejs/vite/blob/c18ce868c4d70873406e9f7d1b2d0a03264d2168/packages/vite/src/node/plugins/importAnalysis.ts#L416
- if (environment.config.consumer === 'client') {
+ if (options?.injectHMRTimestamp || environment.config.consumer === 'client') {
const mod = environment.moduleGraph.getModuleById(id)
if (mod && mod.lastHMRTimestamp > 0) {
url = injectQuery(url, `t=${mod.lastHMRTimestamp}`)