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/e2e/solid-start/dev-ssr-styles/.gitignore b/e2e/solid-start/dev-ssr-styles/.gitignore
new file mode 100644
index 0000000000..950326609c
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/.gitignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+.routeTree.gen.ts
+src/routeTree.gen.ts
+test-results
+playwright-report
+port*.txt
diff --git a/e2e/solid-start/dev-ssr-styles/package.json b/e2e/solid-start/dev-ssr-styles/package.json
new file mode 100644
index 0000000000..b105d6e20a
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "tanstack-solid-start-e2e-dev-ssr-styles",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev --port 3000",
+ "dev:e2e": "vite dev --port $PORT",
+ "build": "vite build && tsc --noEmit",
+ "start": "pnpx srvx --prod -s ../client dist/server/server.js",
+ "test:e2e:dev": "MODE=dev playwright test --project=chromium",
+ "test:e2e": "rm -rf port*.txt; pnpm run test:e2e:dev"
+ },
+ "dependencies": {
+ "@tanstack/solid-router": "workspace:*",
+ "@tanstack/solid-start": "workspace:*",
+ "solid-js": "^1.9.10"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.61.0",
+ "@tanstack/router-core": "workspace:*",
+ "@tanstack/router-e2e-utils": "workspace:*",
+ "@types/node": "^22.10.2",
+ "srvx": "^0.11.9",
+ "typescript": "^6.0.2",
+ "vite": "^8.0.14",
+ "vite-plugin-solid": "^2.11.11"
+ }
+}
diff --git a/e2e/solid-start/dev-ssr-styles/playwright.config.ts b/e2e/solid-start/dev-ssr-styles/playwright.config.ts
new file mode 100644
index 0000000000..0b0e66ad76
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/playwright.config.ts
@@ -0,0 +1,31 @@
+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/solid-start/dev-ssr-styles/src/components/CodeSplitStyledBox.tsx b/e2e/solid-start/dev-ssr-styles/src/components/CodeSplitStyledBox.tsx
new file mode 100644
index 0000000000..02d924dd10
--- /dev/null
+++ b/e2e/solid-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 is styled by a code-split route component.
+
+ )
+}
diff --git a/e2e/solid-start/dev-ssr-styles/src/router.tsx b/e2e/solid-start/dev-ssr-styles/src/router.tsx
new file mode 100644
index 0000000000..73b487af4d
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/src/router.tsx
@@ -0,0 +1,10 @@
+import { createRouter } from '@tanstack/solid-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ scrollRestoration: true,
+ defaultPreload: false,
+ })
+}
diff --git a/e2e/solid-start/dev-ssr-styles/src/routes/__root.tsx b/e2e/solid-start/dev-ssr-styles/src/routes/__root.tsx
new file mode 100644
index 0000000000..47a50b5716
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/src/routes/__root.tsx
@@ -0,0 +1,27 @@
+import {
+ HeadContent,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/solid-router'
+import { HydrationScript } from 'solid-js/web'
+import '~/styles/app.css'
+
+export const Route = createRootRoute({
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/dev-ssr-styles/src/routes/index.tsx b/e2e/solid-start/dev-ssr-styles/src/routes/index.tsx
new file mode 100644
index 0000000000..73e6f1e89d
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/src/routes/index.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/solid-router'
+import { CodeSplitStyledBox } from '../components/CodeSplitStyledBox'
+
+export const Route = createFileRoute('/')({
+ component: Home,
+})
+
+function Home() {
+ return (
+
+ Solid Dev SSR Styles Test
+
+
+ )
+}
diff --git a/e2e/solid-start/dev-ssr-styles/src/styles/app.css b/e2e/solid-start/dev-ssr-styles/src/styles/app.css
new file mode 100644
index 0000000000..9a67f9b153
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/src/styles/app.css
@@ -0,0 +1,5 @@
+body {
+ font-family: sans-serif;
+ margin: 0;
+ padding: 20px;
+}
diff --git a/e2e/solid-start/dev-ssr-styles/src/styles/code-split-route.module.css b/e2e/solid-start/dev-ssr-styles/src/styles/code-split-route.module.css
new file mode 100644
index 0000000000..75a73ca7c3
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/src/styles/code-split-route.module.css
@@ -0,0 +1,6 @@
+.styledBox {
+ --solid-code-split-style-marker: present;
+ background-color: #3b82f6;
+ color: white;
+ padding: 24px;
+}
diff --git a/e2e/solid-start/dev-ssr-styles/tests/app.spec.ts b/e2e/solid-start/dev-ssr-styles/tests/app.spec.ts
new file mode 100644
index 0000000000..25bd317a72
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/tests/app.spec.ts
@@ -0,0 +1,61 @@
+import { expect } from '@playwright/test'
+import { DEV_STYLES_ATTR } from '@tanstack/router-core'
+import { test } from '@tanstack/router-e2e-utils'
+
+const whitelistErrors = [
+ 'Failed to load resource: net::ERR_NAME_NOT_RESOLVED',
+ 'Failed to load resource: the server responded with a status of 504',
+]
+
+test.describe('dev SSR styles', () => {
+ test.use({ whitelistErrors })
+
+ test.describe('with JavaScript disabled', () => {
+ test.use({ javaScriptEnabled: false, whitelistErrors })
+
+ test('includes CSS from a code-split route component during SSR', async ({
+ page,
+ }) => {
+ await page.goto('/')
+
+ const element = page.getByTestId('styled-box')
+ await expect(element).toBeVisible()
+ await expect(element).toHaveCSS('background-color', 'rgb(59, 130, 246)')
+
+ const href = await page
+ .locator(`link[${DEV_STYLES_ATTR}]`)
+ .getAttribute('href')
+ expect(href).toBeTruthy()
+
+ const response = await page.request.get(new URL(href!, page.url()).href)
+ expect(response.ok()).toBeTruthy()
+ expect(await response.text()).toContain(
+ '/* /src/styles/code-split-route.module.css */',
+ )
+ })
+ })
+
+ test('keeps CSS output stable after client modules load', async ({
+ page,
+ }) => {
+ const initialResponsePromise = page.waitForResponse((response) =>
+ new URL(response.url()).pathname.endsWith('/@tanstack-start/styles.css'),
+ )
+ await page.goto('/')
+ await expect(page.getByTestId('styled-box')).toBeVisible()
+ const initialResponse = await initialResponsePromise
+ expect(initialResponse.ok()).toBeTruthy()
+ const initialCss = await initialResponse.text()
+
+ const reloadedResponsePromise = page.waitForResponse((response) =>
+ new URL(response.url()).pathname.endsWith('/@tanstack-start/styles.css'),
+ )
+ await page.reload()
+ await expect(page.getByTestId('styled-box')).toBeVisible()
+ const reloadedResponse = await reloadedResponsePromise
+ expect(reloadedResponse.ok()).toBeTruthy()
+ const reloadedCss = await reloadedResponse.text()
+
+ expect(reloadedCss).toBe(initialCss)
+ })
+})
diff --git a/e2e/solid-start/dev-ssr-styles/tests/setup/global.setup.ts b/e2e/solid-start/dev-ssr-styles/tests/setup/global.setup.ts
new file mode 100644
index 0000000000..337a963417
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/tests/setup/global.setup.ts
@@ -0,0 +1,23 @@
+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: 'home-heading',
+ })
+}
diff --git a/e2e/solid-start/dev-ssr-styles/tests/setup/global.teardown.ts b/e2e/solid-start/dev-ssr-styles/tests/setup/global.teardown.ts
new file mode 100644
index 0000000000..62fd79911c
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/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/solid-start/dev-ssr-styles/tsconfig.json b/e2e/solid-start/dev-ssr-styles/tsconfig.json
new file mode 100644
index 0000000000..c34f9d7c36
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "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,
+ "types": ["vite/client"]
+ }
+}
diff --git a/e2e/solid-start/dev-ssr-styles/vite.config.ts b/e2e/solid-start/dev-ssr-styles/vite.config.ts
new file mode 100644
index 0000000000..be32e5f129
--- /dev/null
+++ b/e2e/solid-start/dev-ssr-styles/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite'
+import { tanstackStart } from '@tanstack/solid-start/plugin/vite'
+import viteSolid from 'vite-plugin-solid'
+
+export default defineConfig({
+ resolve: { tsconfigPaths: true },
+ server: { port: 3000 },
+ plugins: [tanstackStart(), viteSolid({ ssr: true })],
+})
diff --git a/e2e/vue-start/dev-ssr-styles/.gitignore b/e2e/vue-start/dev-ssr-styles/.gitignore
new file mode 100644
index 0000000000..950326609c
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/.gitignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+.routeTree.gen.ts
+src/routeTree.gen.ts
+test-results
+playwright-report
+port*.txt
diff --git a/e2e/vue-start/dev-ssr-styles/package.json b/e2e/vue-start/dev-ssr-styles/package.json
new file mode 100644
index 0000000000..aa3d2acf56
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "tanstack-vue-start-e2e-dev-ssr-styles",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev --port 3000",
+ "dev:e2e": "vite dev --port $PORT",
+ "build": "vite build && tsc --noEmit",
+ "start": "pnpx srvx --prod -s ../client dist/server/server.js",
+ "test:e2e:dev": "MODE=dev playwright test --project=chromium",
+ "test:e2e": "rm -rf port*.txt; pnpm run test:e2e:dev"
+ },
+ "dependencies": {
+ "@tanstack/vue-router": "workspace:*",
+ "@tanstack/vue-start": "workspace:*",
+ "vue": "^3.5.16"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.61.0",
+ "@tanstack/router-core": "workspace:*",
+ "@tanstack/router-e2e-utils": "workspace:*",
+ "@types/node": "^22.10.2",
+ "@vitejs/plugin-vue": "^6.0.5",
+ "@vitejs/plugin-vue-jsx": "^5.1.5",
+ "srvx": "^0.11.9",
+ "typescript": "^6.0.2",
+ "vite": "^8.0.14"
+ }
+}
diff --git a/e2e/vue-start/dev-ssr-styles/playwright.config.ts b/e2e/vue-start/dev-ssr-styles/playwright.config.ts
new file mode 100644
index 0000000000..0b0e66ad76
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/playwright.config.ts
@@ -0,0 +1,31 @@
+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/vue-start/dev-ssr-styles/src/components/SfcStyledBox.vue b/e2e/vue-start/dev-ssr-styles/src/components/SfcStyledBox.vue
new file mode 100644
index 0000000000..bd92215136
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/src/components/SfcStyledBox.vue
@@ -0,0 +1,14 @@
+
+
+ This box is styled by a Vue SFC style block.
+
+
+
+
diff --git a/e2e/vue-start/dev-ssr-styles/src/router.tsx b/e2e/vue-start/dev-ssr-styles/src/router.tsx
new file mode 100644
index 0000000000..c0d7f2b200
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/src/router.tsx
@@ -0,0 +1,10 @@
+import { createRouter } from '@tanstack/vue-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ scrollRestoration: true,
+ defaultPreload: false,
+ })
+}
diff --git a/e2e/vue-start/dev-ssr-styles/src/routes/__root.tsx b/e2e/vue-start/dev-ssr-styles/src/routes/__root.tsx
new file mode 100644
index 0000000000..0dec479a07
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/src/routes/__root.tsx
@@ -0,0 +1,27 @@
+import {
+ Body,
+ HeadContent,
+ Html,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/vue-router'
+import '~/styles/app.css'
+
+export const Route = createRootRoute({
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/e2e/vue-start/dev-ssr-styles/src/routes/index.tsx b/e2e/vue-start/dev-ssr-styles/src/routes/index.tsx
new file mode 100644
index 0000000000..733bc1d559
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/src/routes/index.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/vue-router'
+import SfcStyledBox from '../components/SfcStyledBox.vue'
+
+export const Route = createFileRoute('/')({
+ component: Home,
+})
+
+function Home() {
+ return (
+
+ Vue Dev SSR Styles Test
+
+
+ )
+}
diff --git a/e2e/vue-start/dev-ssr-styles/src/styles/app.css b/e2e/vue-start/dev-ssr-styles/src/styles/app.css
new file mode 100644
index 0000000000..9a67f9b153
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/src/styles/app.css
@@ -0,0 +1,5 @@
+body {
+ font-family: sans-serif;
+ margin: 0;
+ padding: 20px;
+}
diff --git a/e2e/vue-start/dev-ssr-styles/src/vue-shim.d.ts b/e2e/vue-start/dev-ssr-styles/src/vue-shim.d.ts
new file mode 100644
index 0000000000..fe00a856b2
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/src/vue-shim.d.ts
@@ -0,0 +1,6 @@
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+
+ const component: DefineComponent
+ export default component
+}
diff --git a/e2e/vue-start/dev-ssr-styles/tests/app.spec.ts b/e2e/vue-start/dev-ssr-styles/tests/app.spec.ts
new file mode 100644
index 0000000000..0e052441b7
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/tests/app.spec.ts
@@ -0,0 +1,61 @@
+import { expect } from '@playwright/test'
+import { DEV_STYLES_ATTR } from '@tanstack/router-core'
+import { test } from '@tanstack/router-e2e-utils'
+
+const whitelistErrors = [
+ 'Failed to load resource: net::ERR_NAME_NOT_RESOLVED',
+ 'Failed to load resource: the server responded with a status of 504',
+]
+
+test.describe('dev SSR styles', () => {
+ test.use({ whitelistErrors })
+
+ test.describe('with JavaScript disabled', () => {
+ test.use({ javaScriptEnabled: false, whitelistErrors })
+
+ test('includes a Vue SFC style block during SSR', async ({ page }) => {
+ await page.goto('/')
+
+ const element = page.getByTestId('styled-box')
+ await expect(element).toBeVisible()
+ await expect(element).toHaveCSS('background-color', 'rgb(126, 34, 206)')
+
+ const href = await page
+ .locator(`link[${DEV_STYLES_ATTR}]`)
+ .getAttribute('href')
+ expect(href).toBeTruthy()
+
+ const response = await page.request.get(new URL(href!, page.url()).href)
+ expect(response.ok()).toBeTruthy()
+ const css = await response.text()
+ expect(css).toContain('--vue-sfc-style-marker')
+ expect(css).toMatch(
+ /\/src\/components\/SfcStyledBox\.vue\?vue&type=style[^\n]*&lang\.css/,
+ )
+ })
+ })
+
+ test('keeps CSS output stable after client modules load', async ({
+ page,
+ }) => {
+ const initialResponsePromise = page.waitForResponse((response) =>
+ new URL(response.url()).pathname.endsWith('/@tanstack-start/styles.css'),
+ )
+ await page.goto('/')
+ await expect(page.getByTestId('styled-box')).toBeVisible()
+ const initialResponse = await initialResponsePromise
+ expect(initialResponse.ok()).toBeTruthy()
+ const initialCss = await initialResponse.text()
+
+ const reloadedResponsePromise = page.waitForResponse((response) =>
+ new URL(response.url()).pathname.endsWith('/@tanstack-start/styles.css'),
+ )
+ await page.reload()
+ await expect(page.getByTestId('styled-box')).toBeVisible()
+ const reloadedResponse = await reloadedResponsePromise
+ expect(reloadedResponse.ok()).toBeTruthy()
+ const reloadedCss = await reloadedResponse.text()
+
+ expect(reloadedCss).toBe(initialCss)
+ })
+})
diff --git a/e2e/vue-start/dev-ssr-styles/tests/setup/global.setup.ts b/e2e/vue-start/dev-ssr-styles/tests/setup/global.setup.ts
new file mode 100644
index 0000000000..337a963417
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/tests/setup/global.setup.ts
@@ -0,0 +1,23 @@
+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: 'home-heading',
+ })
+}
diff --git a/e2e/vue-start/dev-ssr-styles/tests/setup/global.teardown.ts b/e2e/vue-start/dev-ssr-styles/tests/setup/global.teardown.ts
new file mode 100644
index 0000000000..62fd79911c
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/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/vue-start/dev-ssr-styles/tsconfig.json b/e2e/vue-start/dev-ssr-styles/tsconfig.json
new file mode 100644
index 0000000000..898ea214b1
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "include": ["**/*.ts", "**/*.tsx", "**/*.vue"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "preserve",
+ "jsxImportSource": "vue",
+ "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,
+ "types": ["vite/client"]
+ }
+}
diff --git a/e2e/vue-start/dev-ssr-styles/vite.config.ts b/e2e/vue-start/dev-ssr-styles/vite.config.ts
new file mode 100644
index 0000000000..9d2cd88252
--- /dev/null
+++ b/e2e/vue-start/dev-ssr-styles/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite'
+import { tanstackStart } from '@tanstack/vue-start/plugin/vite'
+import vue from '@vitejs/plugin-vue'
+import vueJsx from '@vitejs/plugin-vue-jsx'
+
+export default defineConfig({
+ resolve: { tsconfigPaths: true },
+ server: { port: 3000 },
+ plugins: [tanstackStart(), vue(), vueJsx()],
+})
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..447fbaf36f 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