From 0f552622b87fdb34f0232d356442653bb0645d1c Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 15 Jul 2026 23:20:34 +0200
Subject: [PATCH 1/2] fix(start): preserve dev SSR CSS order
fixes #7794
---
.changeset/fix-dev-ssr-css-order.md | 5 +
.../src/components/CodeSplitStyledBox.tsx | 9 +
.../dev-ssr-styles/src/routeTree.gen.ts | 24 +-
.../src/routes/css-import-order.tsx | 17 +
.../dev-ssr-styles/src/routes/index.tsx | 5 +-
.../dev-ssr-styles/src/styles/app.css | 7 -
.../src/styles/code-split-route.module.css | 6 +
.../src/styles/css-import-base.css | 5 +
.../src/styles/css-import-order.css | 7 +
.../dev-ssr-styles/tests/app.spec.ts | 65 +-
.../src/vite/dev-server-plugin/dev-styles.ts | 293 +++++----
.../src/vite/dev-server-plugin/plugin.ts | 34 +-
.../tests/vite/dev-styles.test.ts | 610 ++++++++++++++++++
13 files changed, 923 insertions(+), 164 deletions(-)
create mode 100644 .changeset/fix-dev-ssr-css-order.md
create mode 100644 e2e/react-start/dev-ssr-styles/src/components/CodeSplitStyledBox.tsx
create mode 100644 e2e/react-start/dev-ssr-styles/src/routes/css-import-order.tsx
create mode 100644 e2e/react-start/dev-ssr-styles/src/styles/code-split-route.module.css
create mode 100644 e2e/react-start/dev-ssr-styles/src/styles/css-import-base.css
create mode 100644 e2e/react-start/dev-ssr-styles/src/styles/css-import-order.css
create mode 100644 packages/start-plugin-core/tests/vite/dev-styles.test.ts
diff --git a/.changeset/fix-dev-ssr-css-order.md b/.changeset/fix-dev-ssr-css-order.md
new file mode 100644
index 0000000000..7dcb6d1108
--- /dev/null
+++ b/.changeset/fix-dev-ssr-css-order.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/start-plugin-core': patch
+---
+
+Fix dev SSR style collection to preserve CSS order without duplicating imported styles, while retaining styles from code-split routes.
diff --git a/e2e/react-start/dev-ssr-styles/src/components/CodeSplitStyledBox.tsx b/e2e/react-start/dev-ssr-styles/src/components/CodeSplitStyledBox.tsx
new file mode 100644
index 0000000000..49d2f96c39
--- /dev/null
+++ b/e2e/react-start/dev-ssr-styles/src/components/CodeSplitStyledBox.tsx
@@ -0,0 +1,9 @@
+import styles from '../styles/code-split-route.module.css'
+
+export function CodeSplitStyledBox() {
+ return (
+
+ This box should have a blue background when dev styles are enabled.
+
+ )
+}
diff --git a/e2e/react-start/dev-ssr-styles/src/routeTree.gen.ts b/e2e/react-start/dev-ssr-styles/src/routeTree.gen.ts
index dceedffdc1..3914ddaecf 100644
--- a/e2e/react-start/dev-ssr-styles/src/routeTree.gen.ts
+++ b/e2e/react-start/dev-ssr-styles/src/routeTree.gen.ts
@@ -9,8 +9,14 @@
// 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 CssImportOrderRouteImport } from './routes/css-import-order'
import { Route as IndexRouteImport } from './routes/index'
+const CssImportOrderRoute = CssImportOrderRouteImport.update({
+ id: '/css-import-order',
+ path: '/css-import-order',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -19,28 +25,39 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/css-import-order': typeof CssImportOrderRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/css-import-order': typeof CssImportOrderRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/css-import-order': typeof CssImportOrderRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/'
+ fullPaths: '/' | '/css-import-order'
fileRoutesByTo: FileRoutesByTo
- to: '/'
- id: '__root__' | '/'
+ to: '/' | '/css-import-order'
+ id: '__root__' | '/' | '/css-import-order'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ CssImportOrderRoute: typeof CssImportOrderRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
+ '/css-import-order': {
+ id: '/css-import-order'
+ path: '/css-import-order'
+ fullPath: '/css-import-order'
+ preLoaderRoute: typeof CssImportOrderRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -53,6 +70,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ CssImportOrderRoute: CssImportOrderRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/e2e/react-start/dev-ssr-styles/src/routes/css-import-order.tsx b/e2e/react-start/dev-ssr-styles/src/routes/css-import-order.tsx
new file mode 100644
index 0000000000..345254bb8c
--- /dev/null
+++ b/e2e/react-start/dev-ssr-styles/src/routes/css-import-order.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute } from '@tanstack/react-router'
+import '../styles/css-import-order.css'
+
+export const Route = createFileRoute('/css-import-order')({
+ component: CssImportOrder,
+})
+
+function CssImportOrder() {
+ return (
+
+ CSS import order
+
+ The route stylesheet should override its imported base stylesheet.
+
+
+ )
+}
diff --git a/e2e/react-start/dev-ssr-styles/src/routes/index.tsx b/e2e/react-start/dev-ssr-styles/src/routes/index.tsx
index f5fadf464d..62fb237750 100644
--- a/e2e/react-start/dev-ssr-styles/src/routes/index.tsx
+++ b/e2e/react-start/dev-ssr-styles/src/routes/index.tsx
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
+import { CodeSplitStyledBox } from '../components/CodeSplitStyledBox'
export const Route = createFileRoute('/')({
component: Home,
@@ -8,9 +9,7 @@ function Home() {
return (
Dev SSR Styles Test
-
- This box should have a blue background when dev styles are enabled.
-
+
)
}
diff --git a/e2e/react-start/dev-ssr-styles/src/styles/app.css b/e2e/react-start/dev-ssr-styles/src/styles/app.css
index 06b48f8a7c..485ee66c4d 100644
--- a/e2e/react-start/dev-ssr-styles/src/styles/app.css
+++ b/e2e/react-start/dev-ssr-styles/src/styles/app.css
@@ -4,10 +4,3 @@ body {
padding: 20px;
background-color: #f0f4f8;
}
-
-.styled-box {
- background-color: #3b82f6;
- color: white;
- padding: 24px;
- border-radius: 12px;
-}
diff --git a/e2e/react-start/dev-ssr-styles/src/styles/code-split-route.module.css b/e2e/react-start/dev-ssr-styles/src/styles/code-split-route.module.css
new file mode 100644
index 0000000000..831fb4cb78
--- /dev/null
+++ b/e2e/react-start/dev-ssr-styles/src/styles/code-split-route.module.css
@@ -0,0 +1,6 @@
+.styledBox {
+ background-color: #3b82f6;
+ color: white;
+ padding: 24px;
+ border-radius: 12px;
+}
diff --git a/e2e/react-start/dev-ssr-styles/src/styles/css-import-base.css b/e2e/react-start/dev-ssr-styles/src/styles/css-import-base.css
new file mode 100644
index 0000000000..4f73ddab9c
--- /dev/null
+++ b/e2e/react-start/dev-ssr-styles/src/styles/css-import-base.css
@@ -0,0 +1,5 @@
+.css-import-order {
+ --css-import-base-marker: issue-7794;
+ background-color: #ffffff;
+ color: #000000;
+}
diff --git a/e2e/react-start/dev-ssr-styles/src/styles/css-import-order.css b/e2e/react-start/dev-ssr-styles/src/styles/css-import-order.css
new file mode 100644
index 0000000000..afcb4797d9
--- /dev/null
+++ b/e2e/react-start/dev-ssr-styles/src/styles/css-import-order.css
@@ -0,0 +1,7 @@
+@import './css-import-base.css';
+
+.css-import-order {
+ background-color: #111827;
+ color: #ffffff;
+ padding: 24px;
+}
diff --git a/e2e/react-start/dev-ssr-styles/tests/app.spec.ts b/e2e/react-start/dev-ssr-styles/tests/app.spec.ts
index cb1ba0b29b..93c91c6ce1 100644
--- a/e2e/react-start/dev-ssr-styles/tests/app.spec.ts
+++ b/e2e/react-start/dev-ssr-styles/tests/app.spec.ts
@@ -22,6 +22,37 @@ test.describe(`dev.ssrStyles (mode=${ssrStylesMode})`, () => {
})
if (ssrStylesMode === 'default') {
+ test('dev CSS order is stable after client modules load', async ({
+ page,
+ }) => {
+ const cssBodies: Array> = []
+ page.on('response', (response) => {
+ if (
+ new URL(response.url()).pathname.endsWith(
+ '/@tanstack-start/styles.css',
+ )
+ ) {
+ cssBodies.push(response.text())
+ }
+ })
+
+ await page.goto('/css-import-order')
+ await expect(page.getByTestId('css-import-order')).toBeVisible()
+ await expect.poll(() => cssBodies.length).toBeGreaterThanOrEqual(1)
+
+ await page.reload()
+ await expect(page.getByTestId('css-import-order')).toBeVisible()
+ await expect.poll(() => cssBodies.length).toBeGreaterThanOrEqual(2)
+
+ const [initialCss, reloadedCss] = await Promise.all(cssBodies.slice(0, 2))
+ expect(reloadedCss).toBe(initialCss)
+ expect(initialCss).toContain('/* /src/styles/app.css */')
+ expect(initialCss).toContain('/* /src/styles/css-import-order.css */')
+ expect(initialCss.indexOf('/* /src/styles/app.css */')).toBeLessThan(
+ initialCss.indexOf('/* /src/styles/css-import-order.css */'),
+ )
+ })
+
test.describe('default (enabled, basepath = vite base)', () => {
test.use({ javaScriptEnabled: false, whitelistErrors })
@@ -52,7 +83,9 @@ test.describe(`dev.ssrStyles (mode=${ssrStylesMode})`, () => {
expect(href).toMatch(/^\/@tanstack-start\/styles\.css/)
})
- test('CSS is applied on initial page load (SSR)', async ({ page }) => {
+ test('CSS from a code-split route component is applied during SSR', async ({
+ page,
+ }) => {
await page.goto('/')
const element = page.getByTestId('styled-box')
@@ -64,6 +97,36 @@ test.describe(`dev.ssrStyles (mode=${ssrStylesMode})`, () => {
)
expect(backgroundColor).toBe('rgb(59, 130, 246)')
})
+
+ test('CSS @import dependencies are not appended after their importer', async ({
+ page,
+ }) => {
+ await page.goto('/css-import-order')
+
+ const element = page.getByTestId('css-import-order')
+ await expect(element).toBeVisible()
+
+ // css-import-order.css imports a white base rule, then overrides it
+ // with this dark background. Collecting the imported file separately
+ // appends the white rule and reverses the intended cascade.
+ const backgroundColor = await element.evaluate(
+ (el) => getComputedStyle(el).backgroundColor,
+ )
+ expect(backgroundColor).toBe('rgb(17, 24, 39)')
+
+ const devStylesHref = await page
+ .locator(`link[${DEV_STYLES_ATTR}]`)
+ .getAttribute('href')
+ expect(devStylesHref).toBeTruthy()
+
+ const cssResponse = await page.request.get(
+ new URL(devStylesHref!, page.url()).href,
+ )
+ expect(cssResponse.ok()).toBeTruthy()
+
+ const css = await cssResponse.text()
+ expect(css.match(/--css-import-base-marker/g)).toHaveLength(1)
+ })
})
}
diff --git a/packages/start-plugin-core/src/vite/dev-server-plugin/dev-styles.ts b/packages/start-plugin-core/src/vite/dev-server-plugin/dev-styles.ts
index 3a74565c42..86325b7575 100644
--- a/packages/start-plugin-core/src/vite/dev-server-plugin/dev-styles.ts
+++ b/packages/start-plugin-core/src/vite/dev-server-plugin/dev-styles.ts
@@ -3,20 +3,11 @@
* Crawls the Vite module graph to collect CSS from the router entry and all its dependencies.
*/
import path from 'node:path'
-import type { ModuleNode, ViteDevServer } from 'vite'
+import type { DevEnvironment, EnvironmentModuleNode } from 'vite'
// CSS file extensions supported by Vite
const CSS_FILE_REGEX =
/\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/
-// CSS modules file pattern - exported for use in plugin hook filters
-// Note: allow query/hash suffix since Vite ids often include them.
-export const CSS_MODULES_REGEX =
- /\.module\.(css|less|sass|scss|styl|stylus)(?:$|[?#])/i
-
-export function normalizeCssModuleCacheKey(idOrFile: string): string {
- const baseId = idOrFile.split('?')[0]!.split('#')[0]!
- return baseId.replace(/\\/g, '/')
-}
// URL params that indicate CSS should not be injected (e.g., ?url, ?inline)
const CSS_SIDE_EFFECT_FREE_PARAMS = ['url', 'inline', 'raw', 'inline-css']
@@ -25,15 +16,13 @@ const VITE_CSS_MARKER = 'const __vite__css = '
const ESCAPE_CSS_COMMENT_START_REGEX = /\/\*/g
const ESCAPE_CSS_COMMENT_END_REGEX = /\*\//g
+const NO_DEPENDENCIES: ReadonlyArray = []
+const cssTransformCache = new WeakMap