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}`)