Skip to content

Commit f4647c4

Browse files
jantimonhi-ogawacodex
authored
fix(rsc): fix server css hmr with cssLinkPrecedence: false (#1188)
Co-authored-by: Hiroshi Ogawa <hi.ogawa.zz@gmail.com> Co-authored-by: Codex <noreply@openai.com>
1 parent 2845b9f commit f4647c4

3 files changed

Lines changed: 105 additions & 5 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { expect, test } from '@playwright/test'
2+
import { setupInlineFixture, useFixture } from './fixture'
3+
import { expectNoReload, waitForHydration } from './helper'
4+
import { defineStarterTest } from './starter'
5+
6+
test.describe('cssLinkPrecedence-false', () => {
7+
const root = 'examples/e2e/temp/cssLinkPrecedence-false'
8+
9+
test.beforeAll(async () => {
10+
await setupInlineFixture({
11+
src: 'examples/starter',
12+
dest: root,
13+
files: {
14+
'vite.config.base.ts': { cp: 'vite.config.ts' },
15+
'vite.config.ts': /* js */ `
16+
import { defineConfig, mergeConfig } from 'vite'
17+
import baseConfig from './vite.config.base.ts'
18+
19+
const overrideConfig = defineConfig({
20+
rsc: {
21+
cssLinkPrecedence: false,
22+
},
23+
})
24+
25+
export default mergeConfig(baseConfig, overrideConfig)
26+
`,
27+
},
28+
})
29+
})
30+
31+
test.describe('dev', () => {
32+
const f = useFixture({ root, mode: 'dev' })
33+
defineStarterTest(f)
34+
35+
// TODO: move css hmr test to `starter.ts`
36+
test('css hmr', async ({ page }) => {
37+
await page.goto(f.url())
38+
await waitForHydration(page)
39+
const card = page.locator('.card').nth(0)
40+
41+
await using _ = await expectNoReload(page)
42+
const editor = f.createEditor('src/index.css')
43+
editor.edit((s) =>
44+
s.replace(
45+
'.card {\n padding: 1rem;',
46+
`.card {\n padding: 1rem; background-color: rgb(255, 0, 200);`,
47+
),
48+
)
49+
await expect(card).toHaveCSS('background-color', 'rgb(255, 0, 200)')
50+
51+
editor.reset()
52+
await expect(card).not.toHaveCSS('background-color', 'rgb(255, 0, 200)')
53+
})
54+
})
55+
56+
test.describe('build', () => {
57+
const f = useFixture({ root, mode: 'build' })
58+
defineStarterTest(f)
59+
})
60+
})

packages/plugin-rsc/src/plugin.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,20 @@ export default function vitePluginRsc(
722722
async hotUpdate(ctx) {
723723
if (isCSSRequest(ctx.file)) {
724724
if (this.environment.name === 'client') {
725-
return
725+
const cssLinkPrecedence = rscPluginOptions.cssLinkPrecedence ?? true
726+
if (cssLinkPrecedence) return
727+
728+
// Without stylesheet precedence, React owns swapping stylesheet link
729+
// for server css hmr by reconciling new link with <link href="...?t=..." >,
730+
// so we filter out `css` type to prevent triggering Vite's `css-update` hmr,
731+
// which tries to swap the same link.
732+
// we keep `js` type hmr to trigger hmr for css side effect import on client environment
733+
// (though probably css imported both client and server don't behave well.)
734+
const rscMod =
735+
ctx.server.environments.rsc?.moduleGraph.getModuleById(ctx.file)
736+
if (rscMod) {
737+
return ctx.modules.filter((mod) => mod.type !== 'css')
738+
}
726739
}
727740
}
728741

@@ -2229,7 +2242,7 @@ function vitePluginRscCss(
22292242
const visitedFiles = new Set<string>()
22302243

22312244
function recurse(id: string) {
2232-
if (visited.has(id)) {
2245+
if (visited.has(id) || parseCssVirtual(id)) {
22332246
return
22342247
}
22352248
visited.add(id)
@@ -2240,6 +2253,9 @@ function vitePluginRscCss(
22402253
for (const next of mod?.importedModules ?? []) {
22412254
if (next.id) {
22422255
if (isCSSRequest(next.id)) {
2256+
if (next.file) {
2257+
visitedFiles.add(next.file)
2258+
}
22432259
if (hasSpecialCssQuery(next.id)) {
22442260
continue
22452261
}
@@ -2253,9 +2269,32 @@ function vitePluginRscCss(
22532269

22542270
recurse(entryId)
22552271

2256-
// this doesn't include ?t= query so that RSC <link /> won't keep adding styles.
2272+
// CSS links emitted from RSC participate in two different HMR strategies.
2273+
//
2274+
// cssLinkPrecedence: true (default)
2275+
// React treats <link precedence="..."> as a stylesheet resource. Keep the
2276+
// RSC-rendered href stable here and let Vite's normal client `css-update`
2277+
// handle edits for the `.css?direct` module. Vite finds the existing
2278+
// stylesheet link by pathname, clones it, rewrites the clone to
2279+
// `?t=<timestamp>`, then removes the previous link after the new one
2280+
// loads. https://github.com/vitejs/vite/blob/a19003516951a3710aab0f2646d78c48b2e5d2ad/packages/vite/src/client/client.ts#L234-L235
2281+
// React does not re-insert that original stable-href resource on
2282+
// later RSC renders, so the Vite-owned timestamped link remains the live
2283+
// stylesheet. This is why RSC must not inject its own timestamp in
2284+
// precedence mode: doing so makes React see every edit as a new resource
2285+
// and append more stylesheet links.
2286+
//
2287+
// cssLinkPrecedence: false
2288+
// React reconciles <link> like a normal DOM element. In this mode RSC owns
2289+
// the link swap: on CSS HMR, the RSC refetch re-renders this resource list
2290+
// with `?t=<lastHMRTimestamp>`.
2291+
// In this mode, we prevent Vite from swapping the same style
2292+
// by filtering out `css-update` hmr in our `rsc` plugin's `hotUpdate` hook above.
2293+
const cssLinkPrecedence = rscCssOptions?.cssLinkPrecedence ?? true
22572294
const hrefs = [...cssIds].map((id) =>
2258-
normalizeViteImportAnalysisUrl(environment, id),
2295+
normalizeViteImportAnalysisUrl(environment, id, {
2296+
injectHMRTimestamp: !cssLinkPrecedence,
2297+
}),
22592298
)
22602299
return { ids: [...cssIds], hrefs, visitedFiles: [...visitedFiles] }
22612300
}

packages/plugin-rsc/src/plugins/vite-utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,12 @@ export function normalizeResolvedIdToUrl(
113113
export function normalizeViteImportAnalysisUrl(
114114
environment: DevEnvironment,
115115
id: string,
116+
options?: { injectHMRTimestamp?: boolean },
116117
): string {
117118
let url = normalizeResolvedIdToUrl(environment, id, { id })
118119

119120
// https://github.com/vitejs/vite/blob/c18ce868c4d70873406e9f7d1b2d0a03264d2168/packages/vite/src/node/plugins/importAnalysis.ts#L416
120-
if (environment.config.consumer === 'client') {
121+
if (options?.injectHMRTimestamp || environment.config.consumer === 'client') {
121122
const mod = environment.moduleGraph.getModuleById(id)
122123
if (mod && mod.lastHMRTimestamp > 0) {
123124
url = injectQuery(url, `t=${mod.lastHMRTimestamp}`)

0 commit comments

Comments
 (0)