diff --git a/.changeset/fix-rsc-stale-css-hmr.md b/.changeset/fix-rsc-stale-css-hmr.md new file mode 100644 index 00000000000..acff77b7ff1 --- /dev/null +++ b/.changeset/fix-rsc-stale-css-hmr.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-start-rsc': patch +--- + +Fix stale CSS surviving HMR edits in dev by skipping preinit outside production diff --git a/e2e/react-start/rsc-hmr/.gitignore b/e2e/react-start/rsc-hmr/.gitignore new file mode 100644 index 00000000000..229709a89de --- /dev/null +++ b/e2e/react-start/rsc-hmr/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +test-results +playwright-report +port*.txt diff --git a/e2e/react-start/rsc-hmr/eslint.config.js b/e2e/react-start/rsc-hmr/eslint.config.js new file mode 100644 index 00000000000..8e6a4151ded --- /dev/null +++ b/e2e/react-start/rsc-hmr/eslint.config.js @@ -0,0 +1,24 @@ +// @ts-check + +import tsParser from '@typescript-eslint/parser' +import startPlugin from '@tanstack/eslint-plugin-start' + +export default [ + { + files: ['src/**/*.{ts,tsx}'], + languageOptions: { + parser: tsParser, + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + '@tanstack/start': startPlugin, + }, + rules: { + '@tanstack/start/no-client-code-in-server-component': 'error', + '@tanstack/start/no-async-client-component': 'error', + }, + }, +] diff --git a/e2e/react-start/rsc-hmr/package.json b/e2e/react-start/rsc-hmr/package.json new file mode 100644 index 00000000000..ae680752213 --- /dev/null +++ b/e2e/react-start/rsc-hmr/package.json @@ -0,0 +1,35 @@ +{ + "name": "tanstack-react-start-e2e-rsc-hmr", + "private": true, + "sideEffects": [ + "**/*.css" + ], + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev --port $PORT", + "build": "vite build && tsc --noEmit", + "test:e2e": "MODE=dev playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-devtools": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/eslint-plugin-start": "workspace:^", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@typescript-eslint/parser": "^8.23.0", + "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-rsc": "^0.5.20", + "eslint": "^9.22.0", + "typescript": "^5.7.2", + "vite": "^8.0.0" + } +} diff --git a/e2e/react-start/rsc-hmr/playwright.config.ts b/e2e/react-start/rsc-hmr/playwright.config.ts new file mode 100644 index 00000000000..0de7b743968 --- /dev/null +++ b/e2e/react-start/rsc-hmr/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests', + workers: 1, + reporter: [['line']], + + globalSetup: './tests/setup/global.setup.ts', + globalTeardown: './tests/setup/global.teardown.ts', + + use: { + baseURL, + }, + + webServer: { + command: `pnpm dev:e2e`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + env: { + VITE_NODE_ENV: 'test', + PORT: String(PORT), + }, + }, + + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], +}) diff --git a/e2e/react-start/rsc-hmr/src/routeTree.gen.ts b/e2e/react-start/rsc-hmr/src/routeTree.gen.ts new file mode 100644 index 00000000000..d5dfc2e5c83 --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/routeTree.gen.ts @@ -0,0 +1,104 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as RscHmrGlobalCssRouteImport } from './routes/rsc-hmr-global-css' +import { Route as RscHmrCssModulesRouteImport } from './routes/rsc-hmr-css-modules' +import { Route as IndexRouteImport } from './routes/index' + +const RscHmrGlobalCssRoute = RscHmrGlobalCssRouteImport.update({ + id: '/rsc-hmr-global-css', + path: '/rsc-hmr-global-css', + getParentRoute: () => rootRouteImport, +} as any) +const RscHmrCssModulesRoute = RscHmrCssModulesRouteImport.update({ + id: '/rsc-hmr-css-modules', + path: '/rsc-hmr-css-modules', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/rsc-hmr-css-modules': typeof RscHmrCssModulesRoute + '/rsc-hmr-global-css': typeof RscHmrGlobalCssRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/rsc-hmr-css-modules': typeof RscHmrCssModulesRoute + '/rsc-hmr-global-css': typeof RscHmrGlobalCssRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/rsc-hmr-css-modules': typeof RscHmrCssModulesRoute + '/rsc-hmr-global-css': typeof RscHmrGlobalCssRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/rsc-hmr-css-modules' | '/rsc-hmr-global-css' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/rsc-hmr-css-modules' | '/rsc-hmr-global-css' + id: '__root__' | '/' | '/rsc-hmr-css-modules' | '/rsc-hmr-global-css' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + RscHmrCssModulesRoute: typeof RscHmrCssModulesRoute + RscHmrGlobalCssRoute: typeof RscHmrGlobalCssRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/rsc-hmr-global-css': { + id: '/rsc-hmr-global-css' + path: '/rsc-hmr-global-css' + fullPath: '/rsc-hmr-global-css' + preLoaderRoute: typeof RscHmrGlobalCssRouteImport + parentRoute: typeof rootRouteImport + } + '/rsc-hmr-css-modules': { + id: '/rsc-hmr-css-modules' + path: '/rsc-hmr-css-modules' + fullPath: '/rsc-hmr-css-modules' + preLoaderRoute: typeof RscHmrCssModulesRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + RscHmrCssModulesRoute: RscHmrCssModulesRoute, + RscHmrGlobalCssRoute: RscHmrGlobalCssRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/rsc-hmr/src/router.tsx b/e2e/react-start/rsc-hmr/src/router.tsx new file mode 100644 index 00000000000..16fd65460dd --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/router.tsx @@ -0,0 +1,12 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: 'intent', + }) + + return router +} diff --git a/e2e/react-start/rsc-hmr/src/routes/__root.tsx b/e2e/react-start/rsc-hmr/src/routes/__root.tsx new file mode 100644 index 00000000000..304776999c3 --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/routes/__root.tsx @@ -0,0 +1,79 @@ +/// +import { + ClientOnly, + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import type { ReactNode } from 'react' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + ) +} + +function RootDocument({ children }: { children: ReactNode }) { + return ( + + + + + + {children} + + + + ) +} + +function RootContent() { + const navLinks = [ + { testId: 'nav-home', to: '/', label: 'Home' }, + { + testId: 'nav-global-css', + to: '/rsc-hmr-global-css', + label: 'Global CSS', + }, + { + testId: 'nav-css-modules', + to: '/rsc-hmr-css-modules', + label: 'CSS Modules', + }, + ] as const + + return ( + <> + + + hydrated + + + + ) +} diff --git a/e2e/react-start/rsc-hmr/src/routes/index.tsx b/e2e/react-start/rsc-hmr/src/routes/index.tsx new file mode 100644 index 00000000000..21387c40c86 --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/routes/index.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

RSC CSS HMR playground

+

+ Open one of the routes above and edit the corresponding CSS file in + src/utils/ to exercise CSS HMR through the RSC renderer. +

+
+ ) +} diff --git a/e2e/react-start/rsc-hmr/src/routes/rsc-hmr-css-modules.tsx b/e2e/react-start/rsc-hmr/src/routes/rsc-hmr-css-modules.tsx new file mode 100644 index 00000000000..705fa81dc72 --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/routes/rsc-hmr-css-modules.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' +import { getCssModulesCardServerComponent } from '~/utils/cssModulesCardServerComponent' + +export const Route = createFileRoute('/rsc-hmr-css-modules')({ + loader: async () => { + const Server = await getCssModulesCardServerComponent() + return { Server } + }, + component: RscHmrCssModules, +}) + +function RscHmrCssModules() { + const { Server } = Route.useLoaderData() + return <>{Server} +} diff --git a/e2e/react-start/rsc-hmr/src/routes/rsc-hmr-global-css.tsx b/e2e/react-start/rsc-hmr/src/routes/rsc-hmr-global-css.tsx new file mode 100644 index 00000000000..4d15ce33bf5 --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/routes/rsc-hmr-global-css.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' +import { getGlobalCssCardServerComponent } from '~/utils/globalCssCardServerComponent' + +export const Route = createFileRoute('/rsc-hmr-global-css')({ + loader: async () => { + const Server = await getGlobalCssCardServerComponent() + return { Server } + }, + component: RscHmrGlobalCss, +}) + +function RscHmrGlobalCss() { + const { Server } = Route.useLoaderData() + return <>{Server} +} diff --git a/e2e/react-start/rsc-hmr/src/utils/CssModulesCard.module.css b/e2e/react-start/rsc-hmr/src/utils/CssModulesCard.module.css new file mode 100644 index 00000000000..b6811c3c80d --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/utils/CssModulesCard.module.css @@ -0,0 +1,13 @@ +.card { + padding: 20px; + border: 1px solid #ddd; + border-radius: 8px; + margin: 20px; +} + +.title { + color: rgb(128, 0, 128); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 1.5rem; +} diff --git a/e2e/react-start/rsc-hmr/src/utils/CssModulesCard.tsx b/e2e/react-start/rsc-hmr/src/utils/CssModulesCard.tsx new file mode 100644 index 00000000000..cf9f844cb51 --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/utils/CssModulesCard.tsx @@ -0,0 +1,12 @@ +/// +import styles from './CssModulesCard.module.css' + +export function CssModulesCard() { + return ( +
+

+ Server Rendered +

+
+ ) +} diff --git a/e2e/react-start/rsc-hmr/src/utils/GlobalCssCard.css b/e2e/react-start/rsc-hmr/src/utils/GlobalCssCard.css new file mode 100644 index 00000000000..b9758a12b71 --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/utils/GlobalCssCard.css @@ -0,0 +1,13 @@ +.rsc-hmr-global-card { + padding: 20px; + border: 1px solid #ddd; + border-radius: 8px; + margin: 20px; +} + +.rsc-hmr-global-title { + color: rgb(128, 0, 128); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 1.5rem; +} diff --git a/e2e/react-start/rsc-hmr/src/utils/GlobalCssCard.tsx b/e2e/react-start/rsc-hmr/src/utils/GlobalCssCard.tsx new file mode 100644 index 00000000000..9df09e9cfbb --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/utils/GlobalCssCard.tsx @@ -0,0 +1,17 @@ +import './GlobalCssCard.css' + +export function GlobalCssCard() { + return ( +
+

+ Server Rendered +

+
+ ) +} diff --git a/e2e/react-start/rsc-hmr/src/utils/cssModulesCardServerComponent.tsx b/e2e/react-start/rsc-hmr/src/utils/cssModulesCardServerComponent.tsx new file mode 100644 index 00000000000..2802bf3067d --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/utils/cssModulesCardServerComponent.tsx @@ -0,0 +1,7 @@ +import { createServerFn } from '@tanstack/react-start' +import { renderServerComponent } from '@tanstack/react-start/rsc' +import { CssModulesCard } from './CssModulesCard' + +export const getCssModulesCardServerComponent = createServerFn({ + method: 'GET', +}).handler(async () => renderServerComponent()) diff --git a/e2e/react-start/rsc-hmr/src/utils/globalCssCardServerComponent.tsx b/e2e/react-start/rsc-hmr/src/utils/globalCssCardServerComponent.tsx new file mode 100644 index 00000000000..eade033ad8a --- /dev/null +++ b/e2e/react-start/rsc-hmr/src/utils/globalCssCardServerComponent.tsx @@ -0,0 +1,7 @@ +import { createServerFn } from '@tanstack/react-start' +import { renderServerComponent } from '@tanstack/react-start/rsc' +import { GlobalCssCard } from './GlobalCssCard' + +export const getGlobalCssCardServerComponent = createServerFn({ + method: 'GET', +}).handler(async () => renderServerComponent()) diff --git a/e2e/react-start/rsc-hmr/tests/hydration.ts b/e2e/react-start/rsc-hmr/tests/hydration.ts new file mode 100644 index 00000000000..ecad9a70c77 --- /dev/null +++ b/e2e/react-start/rsc-hmr/tests/hydration.ts @@ -0,0 +1,8 @@ +import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' + +export async function waitForHydration(page: Page) { + await expect(page.getByTestId('hydrated')).toHaveText('hydrated', { + timeout: 15000, + }) +} diff --git a/e2e/react-start/rsc-hmr/tests/rsc-hmr-css.spec.ts b/e2e/react-start/rsc-hmr/tests/rsc-hmr-css.spec.ts new file mode 100644 index 00000000000..d421ce13481 --- /dev/null +++ b/e2e/react-start/rsc-hmr/tests/rsc-hmr-css.spec.ts @@ -0,0 +1,134 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { waitForHydration } from './hydration' + +type CssCase = { + label: string + route: string + titleTestId: string + cssFile: string +} + +const cases: Array = [ + { + label: '.module.css', + route: '/rsc-hmr-css-modules', + titleTestId: 'rsc-hmr-modules-title', + cssFile: path.join(process.cwd(), 'src/utils/CssModulesCard.module.css'), + }, + { + label: 'global .css', + route: '/rsc-hmr-global-css', + titleTestId: 'rsc-hmr-global-title', + cssFile: path.join(process.cwd(), 'src/utils/GlobalCssCard.css'), + }, +] + +const originalContents: Record = {} + +async function captureOriginals() { + for (const c of cases) { + originalContents[c.cssFile] = await readFile(c.cssFile, 'utf8') + } +} + +const capturePromise = captureOriginals() + +async function restoreOriginals() { + for (const c of cases) { + const original = originalContents[c.cssFile] + if (original === undefined) continue + const current = await readFile(c.cssFile, 'utf8') + if (current !== original) { + await writeFile(c.cssFile, original) + } + } +} + +async function editCss(c: CssCase, updater: (source: string) => string) { + const source = await readFile(c.cssFile, 'utf8') + const updated = updater(source) + if (updated === source) { + throw new Error(`Expected ${c.cssFile} to change during edit`) + } + await writeFile(c.cssFile, updated) +} + +test.describe('rsc css hmr', () => { + test.beforeEach(async () => { + await capturePromise + await restoreOriginals() + }) + + test.afterAll(async () => { + await capturePromise + await restoreOriginals() + }) + + for (const c of cases) { + test(`${c.label}: a css change hot-updates the style`, async ({ page }) => { + await page.goto(c.route) + await waitForHydration(page) + + await expect(page.getByTestId(c.titleTestId)).toHaveCSS( + 'color', + 'rgb(128, 0, 128)', + ) + + await editCss(c, (source) => + source.replace('rgb(128, 0, 128)', 'rgb(255, 0, 0)'), + ) + + await expect(page.getByTestId(c.titleTestId)).toHaveCSS( + 'color', + 'rgb(255, 0, 0)', + ) + }) + + test(`${c.label}: a second css change in the same file hot-updates the style`, async ({ + page, + }) => { + await page.goto(c.route) + await waitForHydration(page) + + await editCss(c, (source) => + source.replace('rgb(128, 0, 128)', 'rgb(255, 0, 0)'), + ) + await expect(page.getByTestId(c.titleTestId)).toHaveCSS( + 'color', + 'rgb(255, 0, 0)', + ) + + await editCss(c, (source) => + source.replace('rgb(255, 0, 0)', 'rgb(0, 0, 255)'), + ) + await expect(page.getByTestId(c.titleTestId)).toHaveCSS( + 'color', + 'rgb(0, 0, 255)', + ) + }) + + test(`${c.label}: removing a css property hot-updates the style`, async ({ + page, + }) => { + await page.goto(c.route) + await waitForHydration(page) + + await expect(page.getByTestId(c.titleTestId)).toHaveCSS( + 'text-transform', + 'uppercase', + ) + + await editCss(c, (source) => + source.replace('text-transform: uppercase;', ''), + ) + + await expect(page.getByTestId(c.titleTestId)).toHaveCSS( + 'text-transform', + 'none', + ) + }) + } +}) diff --git a/e2e/react-start/rsc-hmr/tests/setup/global.setup.ts b/e2e/react-start/rsc-hmr/tests/setup/global.setup.ts new file mode 100644 index 00000000000..871fd6e6e4c --- /dev/null +++ b/e2e/react-start/rsc-hmr/tests/setup/global.setup.ts @@ -0,0 +1,22 @@ +import { + e2eStartDummyServer, + getTestServerPort, + preOptimizeDevServer, + waitForServer, +} from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function setup() { + await e2eStartDummyServer(packageJson.name) + + if (process.env.MODE !== 'dev') return + + const port = await getTestServerPort(packageJson.name) + const baseURL = `http://localhost:${port}` + + await waitForServer(baseURL) + await preOptimizeDevServer({ + baseURL, + readyTestId: 'hydrated', + }) +} diff --git a/e2e/react-start/rsc-hmr/tests/setup/global.teardown.ts b/e2e/react-start/rsc-hmr/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..62fd79911cc --- /dev/null +++ b/e2e/react-start/rsc-hmr/tests/setup/global.teardown.ts @@ -0,0 +1,6 @@ +import { e2eStopDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function teardown() { + await e2eStopDummyServer(packageJson.name) +} diff --git a/e2e/react-start/rsc-hmr/tsconfig.json b/e2e/react-start/rsc-hmr/tsconfig.json new file mode 100644 index 00000000000..cef9369516a --- /dev/null +++ b/e2e/react-start/rsc-hmr/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/react-start/rsc-hmr/vite.config.ts b/e2e/react-start/rsc-hmr/vite.config.ts new file mode 100644 index 00000000000..928246a0166 --- /dev/null +++ b/e2e/react-start/rsc-hmr/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + server: { + port: 3000, + }, + plugins: [ + tanstackStart({ + rsc: { + enabled: true, + }, + }), + rsc(), + viteReact(), + ], +}) diff --git a/packages/react-start-rsc/src/CompositeComponent.tsx b/packages/react-start-rsc/src/CompositeComponent.tsx index e6c81aa69a6..99eda45dd4f 100644 --- a/packages/react-start-rsc/src/CompositeComponent.tsx +++ b/packages/react-start-rsc/src/CompositeComponent.tsx @@ -4,6 +4,7 @@ import { Suspense } from 'react' import ReactDOM from 'react-dom' import { SlotProvider } from './SlotContext' +import { preinitCssHrefs } from './preinitCssHrefs' import { RSC_PROXY_GET_TREE, RSC_PROXY_PATH, @@ -76,9 +77,7 @@ function CompositeRenderComponent({ cssHrefs?: ReadonlySet jsPreloads?: ReadonlySet }): React.ReactNode { - for (const href of cssHrefs ?? []) { - ReactDOM.preinit(href, { as: 'style', precedence: 'high' }) - } + preinitCssHrefs(cssHrefs) if (jsPreloads) { for (const href of jsPreloads) { diff --git a/packages/react-start-rsc/src/RscNodeRenderer.tsx b/packages/react-start-rsc/src/RscNodeRenderer.tsx index 430f24ad0f2..ab913617464 100644 --- a/packages/react-start-rsc/src/RscNodeRenderer.tsx +++ b/packages/react-start-rsc/src/RscNodeRenderer.tsx @@ -3,6 +3,7 @@ import { Suspense } from 'react' import ReactDOM from 'react-dom' +import { preinitCssHrefs } from './preinitCssHrefs' import { RSC_PROXY_GET_TREE, RSC_PROXY_PATH, @@ -56,9 +57,7 @@ export function RscNodeRenderer({ data }: { data: any }): React.ReactNode { ) } - for (const href of cssHrefs ?? []) { - ReactDOM.preinit(href, { as: 'style', precedence: 'high' }) - } + preinitCssHrefs(cssHrefs) if (jsPreloads) { for (const href of jsPreloads) { diff --git a/packages/react-start-rsc/src/preinitCssHrefs.ts b/packages/react-start-rsc/src/preinitCssHrefs.ts new file mode 100644 index 00000000000..16c358754f5 --- /dev/null +++ b/packages/react-start-rsc/src/preinitCssHrefs.ts @@ -0,0 +1,16 @@ +import ReactDOM from 'react-dom' + +/** + * Emit a for each CSS href so the browser starts + * fetching early during production SSR + */ +export function preinitCssHrefs(cssHrefs: Iterable | undefined): void { + // Dev: plugin-rsc already emits a per href and refreshes it on HMR + // A preinit is never removed, so stale rules from the original CSS + // survive every edit - therefore skip preinit in dev + if (process.env.NODE_ENV !== 'production') return + if (!cssHrefs) return + for (const href of cssHrefs) { + ReactDOM.preinit(href, { as: 'style', precedence: 'high' }) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81494ebb581..ae5943682b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2243,6 +2243,61 @@ importers: specifier: ^8.0.0 version: 8.0.0(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + e2e/react-start/rsc-hmr: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@playwright/test': + specifier: ^1.57.0 + version: 1.58.0 + '@tanstack/eslint-plugin-start': + specifier: workspace:^ + version: link:../../../packages/eslint-plugin-start + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + '@types/react': + specifier: ^19.2.8 + version: 19.2.9 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.9) + '@typescript-eslint/parser': + specifier: ^8.23.0 + version: 8.57.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.9.3) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.0(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@vitejs/plugin-rsc': + specifier: ^0.5.20 + version: 0.5.20(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.0(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + eslint: + specifier: ^9.22.0 + version: 9.22.0(jiti@2.6.1) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^8.0.0 + version: 8.0.0(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + e2e/react-start/rsc-query: dependencies: '@tanstack/react-query': @@ -22990,10 +23045,6 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.0.1: - resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} - engines: {node: 20 || >=22} - minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -27893,8 +27944,8 @@ snapshots: '@eslint-react/ast@1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: '@eslint-react/eff': 1.26.2 - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/typescript-estree': 8.53.0(typescript@6.0.2) + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@6.0.2) '@typescript-eslint/utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) string-ts: 2.2.1 ts-pattern: 5.6.2 @@ -27910,9 +27961,9 @@ snapshots: '@eslint-react/jsx': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/shared': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/var': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/scope-manager': 8.57.1 '@typescript-eslint/type-utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/types': 8.57.1 '@typescript-eslint/utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) birecord: 0.1.1 ts-pattern: 5.6.2 @@ -27949,8 +28000,8 @@ snapshots: '@eslint-react/ast': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/eff': 1.26.2 '@eslint-react/var': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 '@typescript-eslint/utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) ts-pattern: 5.6.2 transitivePeerDependencies: @@ -27973,8 +28024,8 @@ snapshots: dependencies: '@eslint-react/ast': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/eff': 1.26.2 - '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 '@typescript-eslint/utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) string-ts: 2.2.1 ts-pattern: 5.6.2 @@ -31523,7 +31574,7 @@ snapshots: '@stylistic/eslint-plugin@5.4.0(eslint@9.22.0(jiti@2.6.1))': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.22.0(jiti@2.6.1)) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/types': 8.57.1 eslint: 9.22.0(jiti@2.6.1) eslint-visitor-keys: 4.2.1 espree: 10.4.0 @@ -32380,6 +32431,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.57.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.1 + debug: 4.4.3 + eslint: 9.22.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.57.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: '@typescript-eslint/scope-manager': 8.57.1 @@ -32394,8 +32457,8 @@ snapshots: '@typescript-eslint/project-service@8.44.1(typescript@5.8.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.8.3) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.8.3) + '@typescript-eslint/types': 8.57.1 debug: 4.4.3 typescript: 5.8.3 transitivePeerDependencies: @@ -32403,8 +32466,8 @@ snapshots: '@typescript-eslint/project-service@8.44.1(typescript@5.9.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.2) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.2) + '@typescript-eslint/types': 8.57.1 debug: 4.4.3 typescript: 5.9.2 transitivePeerDependencies: @@ -32412,8 +32475,8 @@ snapshots: '@typescript-eslint/project-service@8.44.1(typescript@6.0.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@6.0.2) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@6.0.2) + '@typescript-eslint/types': 8.57.1 debug: 4.4.3 typescript: 6.0.2 transitivePeerDependencies: @@ -32421,31 +32484,22 @@ snapshots: '@typescript-eslint/project-service@8.53.0(typescript@5.9.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.2) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.2) + '@typescript-eslint/types': 8.57.1 debug: 4.4.3 typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.53.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.53.0(typescript@6.0.2)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@6.0.2) - '@typescript-eslint/types': 8.53.0 - debug: 4.4.3 - typescript: 6.0.2 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.57.1(typescript@6.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@6.0.2) @@ -32514,22 +32568,22 @@ snapshots: dependencies: typescript: 6.0.2 - '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.8.3)': + '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.2)': + dependencies: + typescript: 5.9.2 + + '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.8.3)': dependencies: typescript: 5.8.3 - '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.2)': + '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.2)': dependencies: typescript: 5.9.2 - '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.53.0(typescript@6.0.2)': - dependencies: - typescript: 6.0.2 - '@typescript-eslint/tsconfig-utils@8.57.1(typescript@6.0.2)': dependencies: typescript: 6.0.2 @@ -32666,14 +32720,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.53.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.53.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/visitor-keys': 8.53.0 + '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/visitor-keys': 8.57.1 debug: 4.4.3 - minimatch: 9.0.5 + minimatch: 10.2.4 semver: 7.7.3 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -32681,21 +32735,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.53.0(typescript@6.0.2)': - dependencies: - '@typescript-eslint/project-service': 8.53.0(typescript@6.0.2) - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@6.0.2) - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/visitor-keys': 8.53.0 - debug: 4.4.3 - minimatch: 9.0.5 - semver: 7.7.3 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@6.0.2) - typescript: 6.0.2 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.57.1(typescript@6.0.2)': dependencies: '@typescript-eslint/project-service': 8.57.1(typescript@6.0.2) @@ -34717,7 +34756,7 @@ snapshots: detective-typescript@14.0.0(typescript@5.9.3): dependencies: - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) ast-module-types: 6.0.1 node-source-walk: 7.0.1 typescript: 5.9.3 @@ -35216,13 +35255,13 @@ snapshots: eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.9)(eslint@9.22.0(jiti@2.6.1)): dependencies: - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/types': 8.57.1 comment-parser: 1.4.1 debug: 4.4.3 eslint: 9.22.0(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 - minimatch: 10.0.1 + minimatch: 10.2.4 semver: 7.7.3 stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 @@ -35270,9 +35309,9 @@ snapshots: '@eslint-react/jsx': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/shared': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/var': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/scope-manager': 8.57.1 '@typescript-eslint/type-utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/types': 8.57.1 '@typescript-eslint/utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) eslint: 9.22.0(jiti@2.6.1) string-ts: 2.2.1 @@ -35290,8 +35329,8 @@ snapshots: '@eslint-react/jsx': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/shared': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/var': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 '@typescript-eslint/utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) compare-versions: 6.1.1 eslint: 9.22.0(jiti@2.6.1) @@ -35310,9 +35349,9 @@ snapshots: '@eslint-react/jsx': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/shared': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/var': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/scope-manager': 8.57.1 '@typescript-eslint/type-utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/types': 8.57.1 '@typescript-eslint/utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) eslint: 9.22.0(jiti@2.6.1) string-ts: 2.2.1 @@ -35333,9 +35372,9 @@ snapshots: '@eslint-react/eff': 1.26.2 '@eslint-react/jsx': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/shared': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/scope-manager': 8.57.1 '@typescript-eslint/type-utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/types': 8.57.1 '@typescript-eslint/utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) eslint: 9.22.0(jiti@2.6.1) string-ts: 2.2.1 @@ -35353,8 +35392,8 @@ snapshots: '@eslint-react/jsx': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/shared': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/var': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 '@typescript-eslint/utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) eslint: 9.22.0(jiti@2.6.1) string-ts: 2.2.1 @@ -35372,9 +35411,9 @@ snapshots: '@eslint-react/jsx': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/shared': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) '@eslint-react/var': 1.26.2(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/scope-manager': 8.57.1 '@typescript-eslint/type-utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/types': 8.57.1 '@typescript-eslint/utils': 8.44.1(eslint@9.22.0(jiti@2.6.1))(typescript@6.0.2) compare-versions: 6.1.1 eslint: 9.22.0(jiti@2.6.1) @@ -37183,10 +37222,6 @@ snapshots: minimalistic-assert@1.0.1: {} - minimatch@10.0.1: - dependencies: - brace-expansion: 2.0.1 - minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 @@ -40381,7 +40416,7 @@ snapshots: debug: 4.4.3 eslint: 9.22.0(jiti@2.6.1) eslint-scope: 8.3.0 - eslint-visitor-keys: 4.2.1 + eslint-visitor-keys: 5.0.1 espree: 10.4.0 esquery: 1.6.0 semver: 7.7.3