Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/plugin-rsc/e2e/css-link-precedence.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
47 changes: 43 additions & 4 deletions packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <link href="...?t=..." >,
// 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')
}
}
}

Expand Down Expand Up @@ -2229,7 +2242,7 @@ function vitePluginRscCss(
const visitedFiles = new Set<string>()

function recurse(id: string) {
if (visited.has(id)) {
if (visited.has(id) || parseCssVirtual(id)) {
return
}
visited.add(id)
Expand All @@ -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
}
Expand All @@ -2253,9 +2269,32 @@ function vitePluginRscCss(

recurse(entryId)

// this doesn't include ?t= query so that RSC <link /> won't keep adding styles.
// CSS links emitted from RSC participate in two different HMR strategies.
//
// cssLinkPrecedence: true (default)
// React treats <link precedence="..."> 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=<timestamp>`, 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 <link> 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=<lastHMRTimestamp>`.
// 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] }
}
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin-rsc/src/plugins/vite-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down
Loading