Skip to content
Open
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
169 changes: 169 additions & 0 deletions packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { expect, test } from '@playwright/test'
import { useFixture } from './fixture'
import { expectNoReload, waitForHydration } from './helper'

// Covers CSS HMR for server-only components rendered via nested Flight stream
// (renderToReadableStream + createFromReadableStream — TanStack Start's
// createServerFn / renderServerComponent shape)
// Fixture uses cssLinkPrecedence: false so React 19 Float dedup/swap is off
// and raw HMR path is exercised
test.describe('nested-rsc-css-hmr', () => {
const f = useFixture({
root: 'examples/nested-rsc-css-hmr',
mode: 'dev',
})

test('css hmr through nested RSC Flight stream', async ({ page }) => {
await page.goto(f.url())
await waitForHydration(page)
await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS(
'color',
'rgb(255, 165, 0)',
)

await using _ = await expectNoReload(page)
const editor = f.createEditor('src/nested-rsc/inner.css')
editor.edit((s) => s.replaceAll('rgb(255, 165, 0)', 'rgb(0, 165, 255)'))
await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS(
'color',
'rgb(0, 165, 255)',
)
// Revert is load-bearing: naive fix lands edit 1 but wedges Vite's
// Promise.all racing React's <link> reconcile, blocking later rsc:update
editor.reset()
await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS(
'color',
'rgb(255, 165, 0)',
)
})

// Rule removal (not just value change) checks the unmount path: old <link>
// must drop, else the cascade keeps the stale rule
test('round-trip with property removal does not leave stale link', async ({
page,
}) => {
await page.goto(f.url())
await waitForHydration(page)
await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS(
'color',
'rgb(255, 165, 0)',
)

// [data-rsc-css-href] scopes to RSC-emitted links, ignores Vite/React
// injections
// Two on load: outer Root collectCss + nested Flight collectCss
// Bug shape is "grows per edit", so assert equal-to-initial, not equal-to-1
const innerLinks = page.locator(
'link[rel="stylesheet"][data-rsc-css-href*="inner.css"]',
)
const initialLinkCount = await innerLinks.count()
expect(initialLinkCount).toBeGreaterThan(0)

await using _ = await expectNoReload(page)
const editor = f.createEditor('src/nested-rsc/inner.css')

// Edit 1: change value
editor.edit((s) => s.replaceAll('rgb(255, 165, 0)', 'rgb(0, 165, 255)'))
await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS(
'color',
'rgb(0, 165, 255)',
)
await expect(innerLinks).toHaveCount(initialLinkCount)

// Edit 2: remove rule — falls back to :root color from index.css
// (rgb(33, 53, 71)) — stale <link> would keep the blue
editor.edit((s) =>
s.replaceAll(
'color: rgb(0, 165, 255);',
'/* color: rgb(0, 165, 255); */',
),
)
await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS(
'color',
'rgb(33, 53, 71)',
)
await expect(innerLinks).toHaveCount(initialLinkCount)

// Edit 3: revert — back to original orange
editor.reset()
await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS(
'color',
'rgb(255, 165, 0)',
)
await expect(innerLinks).toHaveCount(initialLinkCount)
})

// card.module.css reached from RSC graph (card.tsx server component) and
// client graph (client-tracker.tsx 'use client' side-effect import) — shape
// TanStack Start hits when a route re-declares an RSC-owned stylesheet on
// the client
// Exercises the hasClientJsImporter branch of hotUpdate: fix filters the
// CSS-typed module out of the HMR payload (so Vite's default client HMR
// doesn't cloneNode+mutate the RSC <link>) while keeping the JS-typed
// wrapper so updateStyle() keeps refreshing <style data-vite-dev-id>
// .module.css on purpose — plain .css has no JS wrapper to keep
// Tests guard the config shape; they don't repro the (timing-sensitive)
// hydration race
test('css module reached by both RSC and client graphs hot-updates across edits', async ({
page,
}) => {
await page.goto(f.url())
await waitForHydration(page)
await expect(page.locator('[data-testid="shared-graph-card"]')).toHaveCSS(
'color',
'rgb(128, 0, 128)',
)

await using _ = await expectNoReload(page)
const editor = f.createEditor('src/shared-graph/card.module.css')

editor.edit((s) => s.replaceAll('rgb(128, 0, 128)', 'rgb(255, 0, 0)'))
await expect(page.locator('[data-testid="shared-graph-card"]')).toHaveCSS(
'color',
'rgb(255, 0, 0)',
)

editor.edit((s) => s.replaceAll('rgb(255, 0, 0)', 'rgb(0, 0, 255)'))
await expect(page.locator('[data-testid="shared-graph-card"]')).toHaveCSS(
'color',
'rgb(0, 0, 255)',
)

editor.reset()
await expect(page.locator('[data-testid="shared-graph-card"]')).toHaveCSS(
'color',
'rgb(128, 0, 128)',
)
})

// Covers the "keep JS wrapper in the HMR payload" half: without wrapper
// self-accept, removed rules stay live on <style data-vite-dev-id>
test('removing a rule from the shared css module falls through the cascade', async ({
page,
}) => {
await page.goto(f.url())
await waitForHydration(page)
await expect(page.locator('[data-testid="shared-graph-card"]')).toHaveCSS(
'text-transform',
'uppercase',
)

await using _ = await expectNoReload(page)
const editor = f.createEditor('src/shared-graph/card.module.css')
editor.edit((s) =>
s.replaceAll(
'text-transform: uppercase;',
'/* text-transform: removed */',
),
)
await expect(page.locator('[data-testid="shared-graph-card"]')).toHaveCSS(
'text-transform',
'none',
)
editor.reset()
await expect(page.locator('[data-testid="shared-graph-card"]')).toHaveCSS(
'text-transform',
'uppercase',
)
})
})
2 changes: 2 additions & 0 deletions packages/plugin-rsc/examples/nested-rsc-css-hmr/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
40 changes: 40 additions & 0 deletions packages/plugin-rsc/examples/nested-rsc-css-hmr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Vite + RSC

This example shows how to set up a React application with [Server Component](https://react.dev/reference/rsc/server-components) features on Vite using [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc).

[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter)

```sh
# run dev server
npm run dev

# build for production and preview
npm run build
npm run preview
```

## API usage

See [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) for the documentation.

- [`vite.config.ts`](./vite.config.ts)
- `@vitejs/plugin-rsc/plugin`
- [`./src/framework/entry.rsc.tsx`](./src/framework/entry.rsc.tsx)
- `@vitejs/plugin-rsc/rsc`
- `import.meta.viteRsc.loadModule`
- [`./src/framework/entry.ssr.tsx`](./src/framework/entry.ssr.tsx)
- `@vitejs/plugin-rsc/ssr`
- `import.meta.viteRsc.loadBootstrapScriptContent`
- `rsc-html-stream/server`
- [`./src/framework/entry.browser.tsx`](./src/framework/entry.browser.tsx)
- `@vitejs/plugin-rsc/browser`
- `rsc-html-stream/client`

## Notes

- [`./src/framework/entry.{browser,rsc,ssr}.tsx`](./src/framework) (with inline comments) provides an overview of how low level RSC (React flight) API can be used to build RSC framework.
- You can use [`vite-plugin-inspect`](https://github.com/antfu-collective/vite-plugin-inspect) to understand how `"use client"` and `"use server"` directives are transformed internally.

## Deployment

See [vite-plugin-rsc-deploy-example](https://github.com/hi-ogawa/vite-plugin-rsc-deploy-example)
24 changes: 24 additions & 0 deletions packages/plugin-rsc/examples/nested-rsc-css-hmr/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@vitejs/plugin-rsc-examples-nested-rsc-css-hmr",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "latest",
"@vitejs/plugin-rsc": "latest",
"rsc-html-stream": "^0.0.7",
"vite": "^8.0.8"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions packages/plugin-rsc/examples/nested-rsc-css-hmr/src/action.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use server'

let serverCounter = 0

export async function getServerCounter() {
return serverCounter
}

export async function updateServerCounter(change: number) {
serverCounter += change
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions packages/plugin-rsc/examples/nested-rsc-css-hmr/src/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

import React from 'react'

export function ClientCounter() {
const [count, setCount] = React.useState(0)

return (
<button onClick={() => setCount((count) => count + 1)}>
Client Counter: {count}
</button>
)
}
Loading