diff --git a/packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts b/packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts new file mode 100644 index 000000000..87b4d6b77 --- /dev/null +++ b/packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts @@ -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 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 + // 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 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 ) while keeping the JS-typed + // wrapper so updateStyle() keeps refreshing