diff --git a/e2e/react-start/css-modules/.gitignore b/e2e/react-start/css-modules/.gitignore new file mode 100644 index 00000000000..950326609ca --- /dev/null +++ b/e2e/react-start/css-modules/.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/react-start/css-modules/.prettierignore b/e2e/react-start/css-modules/.prettierignore new file mode 100644 index 00000000000..083bdb7c4c6 --- /dev/null +++ b/e2e/react-start/css-modules/.prettierignore @@ -0,0 +1 @@ +src/routeTree.gen.ts diff --git a/e2e/react-start/css-modules/package.json b/e2e/react-start/css-modules/package.json new file mode 100644 index 00000000000..a52c3872eda --- /dev/null +++ b/e2e/react-start/css-modules/package.json @@ -0,0 +1,34 @@ +{ + "name": "tanstack-react-start-e2e-css-modules", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev --port $PORT", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e:dev": "MODE=dev playwright test --project=chromium", + "test:e2e:prod": "playwright test --project=chromium", + "test:e2e": "rm -rf port*.txt; pnpm run test:e2e:dev" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "srvx": "^0.10.0", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/react-start/css-modules/playwright.config.ts b/e2e/react-start/css-modules/playwright.config.ts new file mode 100644 index 00000000000..7582bbbb531 --- /dev/null +++ b/e2e/react-start/css-modules/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const isDev = process.env.MODE === 'dev' +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: isDev ? `pnpm dev:e2e` : `pnpm build && PORT=${PORT} pnpm start`, + 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/css-modules/src/router.tsx b/e2e/react-start/css-modules/src/router.tsx new file mode 100644 index 00000000000..83ea998b393 --- /dev/null +++ b/e2e/react-start/css-modules/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: false, + }) + + return router +} diff --git a/e2e/react-start/css-modules/src/routes/__root.tsx b/e2e/react-start/css-modules/src/routes/__root.tsx new file mode 100644 index 00000000000..d44c33b8d0d --- /dev/null +++ b/e2e/react-start/css-modules/src/routes/__root.tsx @@ -0,0 +1,60 @@ +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + +
+ +
+ + + + ) +} diff --git a/e2e/react-start/css-modules/src/routes/index.tsx b/e2e/react-start/css-modules/src/routes/index.tsx new file mode 100644 index 00000000000..f7668802f1e --- /dev/null +++ b/e2e/react-start/css-modules/src/routes/index.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router' +import '~/styles/global.css' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

CSS Collection Test - Global CSS

+

This page tests that global CSS is collected and served during SSR.

+ +
+
+ Global CSS Applied +
+
+ This container should have a blue background, white text, and rounded + corners even with JavaScript disabled. +
+
+
+ ) +} diff --git a/e2e/react-start/css-modules/src/routes/modules.tsx b/e2e/react-start/css-modules/src/routes/modules.tsx new file mode 100644 index 00000000000..3b812a5d4e0 --- /dev/null +++ b/e2e/react-start/css-modules/src/routes/modules.tsx @@ -0,0 +1,28 @@ +/// +import { createFileRoute } from '@tanstack/react-router' +import styles from '~/styles/card.module.css' + +export const Route = createFileRoute('/modules')({ + component: Modules, +}) + +function Modules() { + return ( +
+

CSS Collection Test - CSS Modules

+

+ This page tests that CSS modules are collected and served during SSR. +

+ +
+
+ CSS Module Applied +
+
+ This card should have a green theme with scoped class names even with + JavaScript disabled. +
+
+
+ ) +} diff --git a/e2e/react-start/css-modules/src/styles/card.module.css b/e2e/react-start/css-modules/src/styles/card.module.css new file mode 100644 index 00000000000..fcbcb419aa9 --- /dev/null +++ b/e2e/react-start/css-modules/src/styles/card.module.css @@ -0,0 +1,20 @@ +/* CSS Module for testing scoped styles in dev mode */ + +.card { + background-color: #f0fdf4; /* green-50 */ + padding: 16px; + border-radius: 8px; + border: 1px solid #22c55e; /* green-500 */ +} + +.title { + font-size: 18px; + font-weight: 600; + color: #166534; /* green-800 */ + margin-bottom: 8px; +} + +.content { + font-size: 14px; + color: #15803d; /* green-700 */ +} diff --git a/e2e/react-start/css-modules/src/styles/global.css b/e2e/react-start/css-modules/src/styles/global.css new file mode 100644 index 00000000000..5bef746780e --- /dev/null +++ b/e2e/react-start/css-modules/src/styles/global.css @@ -0,0 +1,19 @@ +/* Global styles for testing CSS collection in dev mode */ + +.global-container { + background-color: #3b82f6; /* blue-500 */ + padding: 24px; + border-radius: 12px; + color: white; +} + +.global-title { + font-size: 24px; + font-weight: bold; + margin-bottom: 16px; +} + +.global-description { + font-size: 16px; + opacity: 0.9; +} diff --git a/e2e/react-start/css-modules/tests/css.spec.ts b/e2e/react-start/css-modules/tests/css.spec.ts new file mode 100644 index 00000000000..ec4955f9b24 --- /dev/null +++ b/e2e/react-start/css-modules/tests/css.spec.ts @@ -0,0 +1,137 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test.describe('CSS styles in SSR (dev mode)', () => { + test.describe('with JavaScript disabled', () => { + test.use({ javaScriptEnabled: false }) + + test('global CSS is applied on initial page load', async ({ page }) => { + await page.goto('/') + + const element = page.getByTestId('global-styled') + await expect(element).toBeVisible() + + // Verify the CSS is applied by checking computed styles + // #3b82f6 (blue-500) in RGB is rgb(59, 130, 246) + const backgroundColor = await element.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + + const padding = await element.evaluate( + (el) => getComputedStyle(el).padding, + ) + expect(padding).toBe('24px') + + const borderRadius = await element.evaluate( + (el) => getComputedStyle(el).borderRadius, + ) + expect(borderRadius).toBe('12px') + }) + + test('CSS modules are applied on initial page load', async ({ page }) => { + await page.goto('/modules') + + const card = page.getByTestId('module-card') + await expect(card).toBeVisible() + + // Verify class is scoped (hashed) + const className = await card.getAttribute('class') + expect(className).toBeTruthy() + expect(className).not.toBe('card') + // The class should contain some hash characters (CSS modules add a hash) + expect(className!.length).toBeGreaterThan(5) + + // Verify computed styles from card.module.css + // #f0fdf4 (green-50) in RGB is rgb(240, 253, 244) + const backgroundColor = await card.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(240, 253, 244)') + + const padding = await card.evaluate((el) => getComputedStyle(el).padding) + expect(padding).toBe('16px') + + const borderRadius = await card.evaluate( + (el) => getComputedStyle(el).borderRadius, + ) + expect(borderRadius).toBe('8px') + }) + + test('global CSS class names are NOT scoped', async ({ page }) => { + await page.goto('/') + + const element = page.getByTestId('global-styled') + await expect(element).toBeVisible() + + // Get the class attribute - it should be the plain class name (not hashed) + const className = await element.getAttribute('class') + expect(className).toBe('global-container') + }) + }) + + test('styles persist after hydration', async ({ page }) => { + await page.goto('/') + + // Wait for hydration + await page.waitForTimeout(1000) + + const element = page.getByTestId('global-styled') + const backgroundColor = await element.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + }) + + test('CSS modules styles persist after hydration', async ({ page }) => { + await page.goto('/modules') + + // Wait for hydration + await page.waitForTimeout(1000) + + const card = page.getByTestId('module-card') + const backgroundColor = await card.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(240, 253, 244)') + }) + + test('styles work correctly after client-side navigation', async ({ + page, + }) => { + // Start from home + await page.goto('/') + await page.waitForTimeout(1000) + + // Verify initial styles + const globalElement = page.getByTestId('global-styled') + await expect(globalElement).toBeVisible() + let backgroundColor = await globalElement.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + + // Navigate to modules page + await page.getByTestId('nav-modules').click() + await page.waitForURL('/modules') + + // Verify CSS modules styles + const card = page.getByTestId('module-card') + await expect(card).toBeVisible() + backgroundColor = await card.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(240, 253, 244)') + + // Navigate back to home + await page.getByTestId('nav-home').click() + await page.waitForURL('/') + + // Verify global styles still work + await expect(globalElement).toBeVisible() + backgroundColor = await globalElement.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + }) +}) diff --git a/e2e/react-start/css-modules/tests/setup/global.setup.ts b/e2e/react-start/css-modules/tests/setup/global.setup.ts new file mode 100644 index 00000000000..3593d10ab90 --- /dev/null +++ b/e2e/react-start/css-modules/tests/setup/global.setup.ts @@ -0,0 +1,6 @@ +import { e2eStartDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function setup() { + await e2eStartDummyServer(packageJson.name) +} diff --git a/e2e/react-start/css-modules/tests/setup/global.teardown.ts b/e2e/react-start/css-modules/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..62fd79911cc --- /dev/null +++ b/e2e/react-start/css-modules/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/css-modules/tsconfig.json b/e2e/react-start/css-modules/tsconfig.json new file mode 100644 index 00000000000..3a9fb7cd716 --- /dev/null +++ b/e2e/react-start/css-modules/tsconfig.json @@ -0,0 +1,22 @@ +{ + "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, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/react-start/css-modules/vite.config.ts b/e2e/react-start/css-modules/vite.config.ts new file mode 100644 index 00000000000..c2c28ae93b7 --- /dev/null +++ b/e2e/react-start/css-modules/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) diff --git a/e2e/solid-start/css-modules/.gitignore b/e2e/solid-start/css-modules/.gitignore new file mode 100644 index 00000000000..950326609ca --- /dev/null +++ b/e2e/solid-start/css-modules/.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/css-modules/.prettierignore b/e2e/solid-start/css-modules/.prettierignore new file mode 100644 index 00000000000..083bdb7c4c6 --- /dev/null +++ b/e2e/solid-start/css-modules/.prettierignore @@ -0,0 +1 @@ +src/routeTree.gen.ts diff --git a/e2e/solid-start/css-modules/package.json b/e2e/solid-start/css-modules/package.json new file mode 100644 index 00000000000..27417100bf6 --- /dev/null +++ b/e2e/solid-start/css-modules/package.json @@ -0,0 +1,31 @@ +{ + "name": "tanstack-solid-start-e2e-css-modules", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev --port $PORT", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e:dev": "MODE=dev playwright test --project=chromium", + "test:e2e:prod": "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.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "srvx": "^0.10.0", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-plugin-solid": "^2.11.10", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/solid-start/css-modules/playwright.config.ts b/e2e/solid-start/css-modules/playwright.config.ts new file mode 100644 index 00000000000..7582bbbb531 --- /dev/null +++ b/e2e/solid-start/css-modules/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const isDev = process.env.MODE === 'dev' +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: isDev ? `pnpm dev:e2e` : `pnpm build && PORT=${PORT} pnpm start`, + 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/css-modules/src/router.tsx b/e2e/solid-start/css-modules/src/router.tsx new file mode 100644 index 00000000000..aa5762b4f74 --- /dev/null +++ b/e2e/solid-start/css-modules/src/router.tsx @@ -0,0 +1,12 @@ +import { createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: false, + }) + + return router +} diff --git a/e2e/solid-start/css-modules/src/routes/__root.tsx b/e2e/solid-start/css-modules/src/routes/__root.tsx new file mode 100644 index 00000000000..304d76fb332 --- /dev/null +++ b/e2e/solid-start/css-modules/src/routes/__root.tsx @@ -0,0 +1,62 @@ +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/solid-router' +import { HydrationScript } from 'solid-js/web' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + +
+ +
+ + + + ) +} diff --git a/e2e/solid-start/css-modules/src/routes/index.tsx b/e2e/solid-start/css-modules/src/routes/index.tsx new file mode 100644 index 00000000000..3c7caf6a979 --- /dev/null +++ b/e2e/solid-start/css-modules/src/routes/index.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/solid-router' +import '~/styles/global.css' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

CSS Collection Test - Global CSS

+

This page tests that global CSS is collected and served during SSR.

+ +
+
+ Global CSS Applied +
+
+ This container should have a blue background, white text, and rounded + corners even with JavaScript disabled. +
+
+
+ ) +} diff --git a/e2e/solid-start/css-modules/src/routes/modules.tsx b/e2e/solid-start/css-modules/src/routes/modules.tsx new file mode 100644 index 00000000000..34cd8d36eaa --- /dev/null +++ b/e2e/solid-start/css-modules/src/routes/modules.tsx @@ -0,0 +1,28 @@ +/// +import { createFileRoute } from '@tanstack/solid-router' +import styles from '~/styles/card.module.css' + +export const Route = createFileRoute('/modules')({ + component: Modules, +}) + +function Modules() { + return ( +
+

CSS Collection Test - CSS Modules

+

+ This page tests that CSS modules are collected and served during SSR. +

+ +
+
+ CSS Module Applied +
+
+ This card should have a green theme with scoped class names even with + JavaScript disabled. +
+
+
+ ) +} diff --git a/e2e/solid-start/css-modules/src/styles/card.module.css b/e2e/solid-start/css-modules/src/styles/card.module.css new file mode 100644 index 00000000000..fcbcb419aa9 --- /dev/null +++ b/e2e/solid-start/css-modules/src/styles/card.module.css @@ -0,0 +1,20 @@ +/* CSS Module for testing scoped styles in dev mode */ + +.card { + background-color: #f0fdf4; /* green-50 */ + padding: 16px; + border-radius: 8px; + border: 1px solid #22c55e; /* green-500 */ +} + +.title { + font-size: 18px; + font-weight: 600; + color: #166534; /* green-800 */ + margin-bottom: 8px; +} + +.content { + font-size: 14px; + color: #15803d; /* green-700 */ +} diff --git a/e2e/solid-start/css-modules/src/styles/global.css b/e2e/solid-start/css-modules/src/styles/global.css new file mode 100644 index 00000000000..5bef746780e --- /dev/null +++ b/e2e/solid-start/css-modules/src/styles/global.css @@ -0,0 +1,19 @@ +/* Global styles for testing CSS collection in dev mode */ + +.global-container { + background-color: #3b82f6; /* blue-500 */ + padding: 24px; + border-radius: 12px; + color: white; +} + +.global-title { + font-size: 24px; + font-weight: bold; + margin-bottom: 16px; +} + +.global-description { + font-size: 16px; + opacity: 0.9; +} diff --git a/e2e/solid-start/css-modules/tests/css.spec.ts b/e2e/solid-start/css-modules/tests/css.spec.ts new file mode 100644 index 00000000000..ec4955f9b24 --- /dev/null +++ b/e2e/solid-start/css-modules/tests/css.spec.ts @@ -0,0 +1,137 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test.describe('CSS styles in SSR (dev mode)', () => { + test.describe('with JavaScript disabled', () => { + test.use({ javaScriptEnabled: false }) + + test('global CSS is applied on initial page load', async ({ page }) => { + await page.goto('/') + + const element = page.getByTestId('global-styled') + await expect(element).toBeVisible() + + // Verify the CSS is applied by checking computed styles + // #3b82f6 (blue-500) in RGB is rgb(59, 130, 246) + const backgroundColor = await element.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + + const padding = await element.evaluate( + (el) => getComputedStyle(el).padding, + ) + expect(padding).toBe('24px') + + const borderRadius = await element.evaluate( + (el) => getComputedStyle(el).borderRadius, + ) + expect(borderRadius).toBe('12px') + }) + + test('CSS modules are applied on initial page load', async ({ page }) => { + await page.goto('/modules') + + const card = page.getByTestId('module-card') + await expect(card).toBeVisible() + + // Verify class is scoped (hashed) + const className = await card.getAttribute('class') + expect(className).toBeTruthy() + expect(className).not.toBe('card') + // The class should contain some hash characters (CSS modules add a hash) + expect(className!.length).toBeGreaterThan(5) + + // Verify computed styles from card.module.css + // #f0fdf4 (green-50) in RGB is rgb(240, 253, 244) + const backgroundColor = await card.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(240, 253, 244)') + + const padding = await card.evaluate((el) => getComputedStyle(el).padding) + expect(padding).toBe('16px') + + const borderRadius = await card.evaluate( + (el) => getComputedStyle(el).borderRadius, + ) + expect(borderRadius).toBe('8px') + }) + + test('global CSS class names are NOT scoped', async ({ page }) => { + await page.goto('/') + + const element = page.getByTestId('global-styled') + await expect(element).toBeVisible() + + // Get the class attribute - it should be the plain class name (not hashed) + const className = await element.getAttribute('class') + expect(className).toBe('global-container') + }) + }) + + test('styles persist after hydration', async ({ page }) => { + await page.goto('/') + + // Wait for hydration + await page.waitForTimeout(1000) + + const element = page.getByTestId('global-styled') + const backgroundColor = await element.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + }) + + test('CSS modules styles persist after hydration', async ({ page }) => { + await page.goto('/modules') + + // Wait for hydration + await page.waitForTimeout(1000) + + const card = page.getByTestId('module-card') + const backgroundColor = await card.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(240, 253, 244)') + }) + + test('styles work correctly after client-side navigation', async ({ + page, + }) => { + // Start from home + await page.goto('/') + await page.waitForTimeout(1000) + + // Verify initial styles + const globalElement = page.getByTestId('global-styled') + await expect(globalElement).toBeVisible() + let backgroundColor = await globalElement.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + + // Navigate to modules page + await page.getByTestId('nav-modules').click() + await page.waitForURL('/modules') + + // Verify CSS modules styles + const card = page.getByTestId('module-card') + await expect(card).toBeVisible() + backgroundColor = await card.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(240, 253, 244)') + + // Navigate back to home + await page.getByTestId('nav-home').click() + await page.waitForURL('/') + + // Verify global styles still work + await expect(globalElement).toBeVisible() + backgroundColor = await globalElement.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + }) +}) diff --git a/e2e/solid-start/css-modules/tests/setup/global.setup.ts b/e2e/solid-start/css-modules/tests/setup/global.setup.ts new file mode 100644 index 00000000000..3593d10ab90 --- /dev/null +++ b/e2e/solid-start/css-modules/tests/setup/global.setup.ts @@ -0,0 +1,6 @@ +import { e2eStartDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function setup() { + await e2eStartDummyServer(packageJson.name) +} diff --git a/e2e/solid-start/css-modules/tests/setup/global.teardown.ts b/e2e/solid-start/css-modules/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..62fd79911cc --- /dev/null +++ b/e2e/solid-start/css-modules/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/css-modules/tsconfig.json b/e2e/solid-start/css-modules/tsconfig.json new file mode 100644 index 00000000000..ed8b73fa2dd --- /dev/null +++ b/e2e/solid-start/css-modules/tsconfig.json @@ -0,0 +1,24 @@ +{ + "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, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/solid-start/css-modules/vite.config.ts b/e2e/solid-start/css-modules/vite.config.ts new file mode 100644 index 00000000000..1a2219f4435 --- /dev/null +++ b/e2e/solid-start/css-modules/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteSolid({ ssr: true }), + ], +}) diff --git a/e2e/vue-start/css-modules/.gitignore b/e2e/vue-start/css-modules/.gitignore new file mode 100644 index 00000000000..950326609ca --- /dev/null +++ b/e2e/vue-start/css-modules/.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/css-modules/.prettierignore b/e2e/vue-start/css-modules/.prettierignore new file mode 100644 index 00000000000..083bdb7c4c6 --- /dev/null +++ b/e2e/vue-start/css-modules/.prettierignore @@ -0,0 +1 @@ +src/routeTree.gen.ts diff --git a/e2e/vue-start/css-modules/package.json b/e2e/vue-start/css-modules/package.json new file mode 100644 index 00000000000..b2e267f9b56 --- /dev/null +++ b/e2e/vue-start/css-modules/package.json @@ -0,0 +1,31 @@ +{ + "name": "tanstack-vue-start-e2e-css-modules", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev --port $PORT", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e:dev": "MODE=dev playwright test --project=chromium", + "test:e2e:prod": "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.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@vitejs/plugin-vue-jsx": "^4.1.2", + "srvx": "^0.10.0", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/vue-start/css-modules/playwright.config.ts b/e2e/vue-start/css-modules/playwright.config.ts new file mode 100644 index 00000000000..7582bbbb531 --- /dev/null +++ b/e2e/vue-start/css-modules/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const isDev = process.env.MODE === 'dev' +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: isDev ? `pnpm dev:e2e` : `pnpm build && PORT=${PORT} pnpm start`, + 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/css-modules/src/router.tsx b/e2e/vue-start/css-modules/src/router.tsx new file mode 100644 index 00000000000..7394b7042e8 --- /dev/null +++ b/e2e/vue-start/css-modules/src/router.tsx @@ -0,0 +1,12 @@ +import { createRouter } from '@tanstack/vue-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: false, + }) + + return router +} diff --git a/e2e/vue-start/css-modules/src/routes/__root.tsx b/e2e/vue-start/css-modules/src/routes/__root.tsx new file mode 100644 index 00000000000..39c0110beea --- /dev/null +++ b/e2e/vue-start/css-modules/src/routes/__root.tsx @@ -0,0 +1,62 @@ +import { + Body, + HeadContent, + Html, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/vue-router' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + +
+ +
+ + + + ) +} diff --git a/e2e/vue-start/css-modules/src/routes/index.tsx b/e2e/vue-start/css-modules/src/routes/index.tsx new file mode 100644 index 00000000000..5c52a6e3427 --- /dev/null +++ b/e2e/vue-start/css-modules/src/routes/index.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/vue-router' +import '~/styles/global.css' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

CSS Collection Test - Global CSS

+

This page tests that global CSS is collected and served during SSR.

+ +
+
+ Global CSS Applied +
+
+ This container should have a blue background, white text, and rounded + corners even with JavaScript disabled. +
+
+
+ ) +} diff --git a/e2e/vue-start/css-modules/src/routes/modules.tsx b/e2e/vue-start/css-modules/src/routes/modules.tsx new file mode 100644 index 00000000000..4c1a655ed8f --- /dev/null +++ b/e2e/vue-start/css-modules/src/routes/modules.tsx @@ -0,0 +1,28 @@ +/// +import { createFileRoute } from '@tanstack/vue-router' +import styles from '~/styles/card.module.css' + +export const Route = createFileRoute('/modules')({ + component: Modules, +}) + +function Modules() { + return ( +
+

CSS Collection Test - CSS Modules

+

+ This page tests that CSS modules are collected and served during SSR. +

+ +
+
+ CSS Module Applied +
+
+ This card should have a green theme with scoped class names even with + JavaScript disabled. +
+
+
+ ) +} diff --git a/e2e/vue-start/css-modules/src/styles/card.module.css b/e2e/vue-start/css-modules/src/styles/card.module.css new file mode 100644 index 00000000000..fcbcb419aa9 --- /dev/null +++ b/e2e/vue-start/css-modules/src/styles/card.module.css @@ -0,0 +1,20 @@ +/* CSS Module for testing scoped styles in dev mode */ + +.card { + background-color: #f0fdf4; /* green-50 */ + padding: 16px; + border-radius: 8px; + border: 1px solid #22c55e; /* green-500 */ +} + +.title { + font-size: 18px; + font-weight: 600; + color: #166534; /* green-800 */ + margin-bottom: 8px; +} + +.content { + font-size: 14px; + color: #15803d; /* green-700 */ +} diff --git a/e2e/vue-start/css-modules/src/styles/global.css b/e2e/vue-start/css-modules/src/styles/global.css new file mode 100644 index 00000000000..5bef746780e --- /dev/null +++ b/e2e/vue-start/css-modules/src/styles/global.css @@ -0,0 +1,19 @@ +/* Global styles for testing CSS collection in dev mode */ + +.global-container { + background-color: #3b82f6; /* blue-500 */ + padding: 24px; + border-radius: 12px; + color: white; +} + +.global-title { + font-size: 24px; + font-weight: bold; + margin-bottom: 16px; +} + +.global-description { + font-size: 16px; + opacity: 0.9; +} diff --git a/e2e/vue-start/css-modules/tests/css.spec.ts b/e2e/vue-start/css-modules/tests/css.spec.ts new file mode 100644 index 00000000000..ec4955f9b24 --- /dev/null +++ b/e2e/vue-start/css-modules/tests/css.spec.ts @@ -0,0 +1,137 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test.describe('CSS styles in SSR (dev mode)', () => { + test.describe('with JavaScript disabled', () => { + test.use({ javaScriptEnabled: false }) + + test('global CSS is applied on initial page load', async ({ page }) => { + await page.goto('/') + + const element = page.getByTestId('global-styled') + await expect(element).toBeVisible() + + // Verify the CSS is applied by checking computed styles + // #3b82f6 (blue-500) in RGB is rgb(59, 130, 246) + const backgroundColor = await element.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + + const padding = await element.evaluate( + (el) => getComputedStyle(el).padding, + ) + expect(padding).toBe('24px') + + const borderRadius = await element.evaluate( + (el) => getComputedStyle(el).borderRadius, + ) + expect(borderRadius).toBe('12px') + }) + + test('CSS modules are applied on initial page load', async ({ page }) => { + await page.goto('/modules') + + const card = page.getByTestId('module-card') + await expect(card).toBeVisible() + + // Verify class is scoped (hashed) + const className = await card.getAttribute('class') + expect(className).toBeTruthy() + expect(className).not.toBe('card') + // The class should contain some hash characters (CSS modules add a hash) + expect(className!.length).toBeGreaterThan(5) + + // Verify computed styles from card.module.css + // #f0fdf4 (green-50) in RGB is rgb(240, 253, 244) + const backgroundColor = await card.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(240, 253, 244)') + + const padding = await card.evaluate((el) => getComputedStyle(el).padding) + expect(padding).toBe('16px') + + const borderRadius = await card.evaluate( + (el) => getComputedStyle(el).borderRadius, + ) + expect(borderRadius).toBe('8px') + }) + + test('global CSS class names are NOT scoped', async ({ page }) => { + await page.goto('/') + + const element = page.getByTestId('global-styled') + await expect(element).toBeVisible() + + // Get the class attribute - it should be the plain class name (not hashed) + const className = await element.getAttribute('class') + expect(className).toBe('global-container') + }) + }) + + test('styles persist after hydration', async ({ page }) => { + await page.goto('/') + + // Wait for hydration + await page.waitForTimeout(1000) + + const element = page.getByTestId('global-styled') + const backgroundColor = await element.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + }) + + test('CSS modules styles persist after hydration', async ({ page }) => { + await page.goto('/modules') + + // Wait for hydration + await page.waitForTimeout(1000) + + const card = page.getByTestId('module-card') + const backgroundColor = await card.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(240, 253, 244)') + }) + + test('styles work correctly after client-side navigation', async ({ + page, + }) => { + // Start from home + await page.goto('/') + await page.waitForTimeout(1000) + + // Verify initial styles + const globalElement = page.getByTestId('global-styled') + await expect(globalElement).toBeVisible() + let backgroundColor = await globalElement.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + + // Navigate to modules page + await page.getByTestId('nav-modules').click() + await page.waitForURL('/modules') + + // Verify CSS modules styles + const card = page.getByTestId('module-card') + await expect(card).toBeVisible() + backgroundColor = await card.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(240, 253, 244)') + + // Navigate back to home + await page.getByTestId('nav-home').click() + await page.waitForURL('/') + + // Verify global styles still work + await expect(globalElement).toBeVisible() + backgroundColor = await globalElement.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(59, 130, 246)') + }) +}) diff --git a/e2e/vue-start/css-modules/tests/setup/global.setup.ts b/e2e/vue-start/css-modules/tests/setup/global.setup.ts new file mode 100644 index 00000000000..3593d10ab90 --- /dev/null +++ b/e2e/vue-start/css-modules/tests/setup/global.setup.ts @@ -0,0 +1,6 @@ +import { e2eStartDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function setup() { + await e2eStartDummyServer(packageJson.name) +} diff --git a/e2e/vue-start/css-modules/tests/setup/global.teardown.ts b/e2e/vue-start/css-modules/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..62fd79911cc --- /dev/null +++ b/e2e/vue-start/css-modules/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/css-modules/tsconfig.json b/e2e/vue-start/css-modules/tsconfig.json new file mode 100644 index 00000000000..8bf3d33789b --- /dev/null +++ b/e2e/vue-start/css-modules/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "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, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/vue-start/css-modules/vite.config.ts b/e2e/vue-start/css-modules/vite.config.ts new file mode 100644 index 00000000000..d7b96c1c2f8 --- /dev/null +++ b/e2e/vue-start/css-modules/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/vue-start/plugin/vite' +import vueJsx from '@vitejs/plugin-vue-jsx' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + vueJsx(), + ], +}) diff --git a/packages/react-router/src/HeadContent.tsx b/packages/react-router/src/HeadContent.tsx index 3a30c521dc5..8da3b5b279b 100644 --- a/packages/react-router/src/HeadContent.tsx +++ b/packages/react-router/src/HeadContent.tsx @@ -203,6 +203,39 @@ export const useTags = () => { ) } +/** + * Renders a stylesheet link for dev mode CSS collection. + * On the server, renders the full link with route-scoped CSS URL. + * On the client, renders the same link to avoid hydration mismatch, + * then removes it after hydration since Vite's HMR handles CSS updates. + */ +function DevStylesLink() { + const router = useRouter() + + const routeIds = useRouterState({ + select: (state) => state.matches.map((match) => match.routeId), + }) + + React.useEffect(() => { + // After hydration, remove the SSR-rendered dev styles link + document + .querySelectorAll('[data-tanstack-start-dev-styles]') + .forEach((el) => el.remove()) + }, []) + + // Build the same href on both server and client for hydration match + const href = `/@tanstack-start/styles.css?routes=${encodeURIComponent(routeIds.join(','))}` + + return ( + + ) +} + /** * Render route-managed head tags (title, meta, links, styles, head scripts). * Place inside the document head of your app shell. @@ -212,9 +245,14 @@ export function HeadContent() { const tags = useTags() const router = useRouter() const nonce = router.options.ssr?.nonce - return tags.map((tag) => ( - - )) + return ( + <> + {process.env.NODE_ENV !== 'production' && } + {tags.map((tag) => ( + + ))} + + ) } function uniqBy(arr: Array, fn: (item: T) => string) { diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index 315b3e528e5..ddd11cf1a79 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -217,7 +217,7 @@ describe('ssr HeadContent', () => { , ) expect(html).toEqual( - `Index`, + `Index`, ) }) }) diff --git a/packages/solid-router/src/HeadContent.tsx b/packages/solid-router/src/HeadContent.tsx index 32326976764..2d52ac88a7a 100644 --- a/packages/solid-router/src/HeadContent.tsx +++ b/packages/solid-router/src/HeadContent.tsx @@ -1,6 +1,6 @@ import * as Solid from 'solid-js' import { MetaProvider } from '@solidjs/meta' -import { For } from 'solid-js' +import { For, onMount } from 'solid-js' import { escapeHtml } from '@tanstack/router-core' import { Asset } from './Asset' import { useRouter } from './useRouter' @@ -197,6 +197,31 @@ export const useTags = () => { ) } +/** + * Renders a stylesheet link for dev mode CSS collection. + * On the server, renders the full link with route-scoped CSS URL. + * On the client, renders the same link to avoid hydration mismatch, + * then removes it after hydration since Vite's HMR handles CSS updates. + */ +function DevStylesLink() { + const routeIds = useRouterState({ + select: (state) => state.matches.map((match) => match.routeId), + }) + + onMount(() => { + // After hydration, remove the SSR-rendered dev styles link + document + .querySelectorAll('[data-tanstack-start-dev-styles]') + .forEach((el) => el.remove()) + }) + + // Build the same href on both server and client for hydration match + const href = () => + `/@tanstack-start/styles.css?routes=${encodeURIComponent(routeIds().join(','))}` + + return +} + /** * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route. * When using full document hydration (hydrating from ``), this component should be rendered in the `` @@ -208,6 +233,7 @@ export function HeadContent() { return ( + {process.env.NODE_ENV !== 'production' && } {(tag) => } ) diff --git a/packages/start-plugin-core/src/dev-server-plugin/dev-styles.ts b/packages/start-plugin-core/src/dev-server-plugin/dev-styles.ts new file mode 100644 index 00000000000..4c226ae425c --- /dev/null +++ b/packages/start-plugin-core/src/dev-server-plugin/dev-styles.ts @@ -0,0 +1,176 @@ +/** + * CSS collection for dev mode. + * Crawls the Vite module graph to collect CSS from the router entry and all its dependencies. + */ +import type { ModuleNode, ViteDevServer } from 'vite' + +// CSS file extensions supported by Vite +const CSS_FILE_REGEX = + /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/ +// URL params that indicate CSS should not be injected (e.g., ?url, ?inline) +const CSS_SIDE_EFFECT_FREE_PARAMS = ['url', 'inline', 'raw', 'inline-css'] + +function isCssFile(file: string): boolean { + return CSS_FILE_REGEX.test(file) +} + +function hasCssSideEffectFreeParam(url: string): boolean { + const queryString = url.split('?')[1] + if (!queryString) return false + + const params = new URLSearchParams(queryString) + return CSS_SIDE_EFFECT_FREE_PARAMS.some( + (param) => + params.get(param) === '' && + !url.includes(`?${param}=`) && + !url.includes(`&${param}=`), + ) +} + +export interface CollectDevStylesOptions { + viteDevServer: ViteDevServer + entries: Array +} + +/** + * Collect CSS content from the module graph starting from the given entry points. + */ +export async function collectDevStyles( + opts: CollectDevStylesOptions, +): Promise { + const { viteDevServer, entries } = opts + const styles: Map = new Map() + const visited = new Set() + + for (const entry of entries) { + const normalizedPath = entry.replace(/\\/g, '/') + let node = await viteDevServer.moduleGraph.getModuleById(normalizedPath) + + // If module isn't in the graph yet, request it to trigger transform + if (!node) { + try { + await viteDevServer.transformRequest(normalizedPath) + } catch (err) { + // Ignore - the module might not exist yet + } + node = await viteDevServer.moduleGraph.getModuleById(normalizedPath) + } + + if (node) { + await crawlModuleForCss(viteDevServer, node, visited, styles) + } + } + + if (styles.size === 0) return undefined + + return Array.from(styles.entries()) + .map(([fileName, css]) => { + const escapedFileName = fileName + .replace(/\/\*/g, '/\\*') + .replace(/\*\//g, '*\\/') + return `\n/* ${escapedFileName} */\n${css}` + }) + .join('\n') +} + +async function crawlModuleForCss( + vite: ViteDevServer, + node: ModuleNode, + visited: Set, + styles: Map, +): Promise { + if (visited.has(node)) return + visited.add(node) + + const branches: Array> = [] + + // Ensure the module has been transformed to populate its deps + // This is important for code-split modules that may not have been processed yet + if (!node.ssrTransformResult) { + try { + await vite.transformRequest(node.url, { ssr: true }) + // Re-fetch the node to get updated state + const updatedNode = await vite.moduleGraph.getModuleByUrl(node.url) + if (updatedNode) { + node = updatedNode + } + } catch { + // Ignore transform errors - the module might not be transformable + } + } + + // Check if this is a CSS file + if ( + node.file && + isCssFile(node.file) && + !hasCssSideEffectFreeParam(node.url) + ) { + const css = await loadCssContent(vite, node) + if (css) { + styles.set(node.url, css) + } + } + + // Crawl dependencies using ssrTransformResult.deps and importedModules + // We need both because: + // 1. ssrTransformResult.deps has resolved URLs for SSR dependencies + // 2. importedModules may contain CSS files and code-split modules not in SSR deps + const depsFromSsr = node.ssrTransformResult?.deps ?? [] + const urlsToVisit = new Set(depsFromSsr) + + // Check importedModules for CSS files and additional modules + for (const importedNode of node.importedModules) { + if (importedNode.file && isCssFile(importedNode.file)) { + // CSS files often don't appear in ssrTransformResult.deps, add them explicitly + branches.push(crawlModuleForCss(vite, importedNode, visited, styles)) + } else if (!urlsToVisit.has(importedNode.url)) { + // Also add non-CSS imports that aren't in SSR deps (e.g., code-split modules) + urlsToVisit.add(importedNode.url) + } + } + + for (const depUrl of urlsToVisit) { + branches.push( + (async () => { + const depNode = await vite.moduleGraph.getModuleByUrl(depUrl) + if (depNode) { + await crawlModuleForCss(vite, depNode, visited, styles) + } + })(), + ) + } + + await Promise.all(branches) +} + +async function loadCssContent( + vite: ViteDevServer, + node: ModuleNode, +): Promise { + // For ALL CSS files (including CSS modules), get the transformed content + // and extract __vite__css. Vite's transform puts the final CSS (with hashed + // class names for modules) into the __vite__css variable. + const transformResult = await vite.transformRequest(node.url) + if (!transformResult?.code) return undefined + + // Extract CSS content from Vite's transformed module + return extractCssFromViteModule(transformResult.code) +} + +/** + * Extract CSS string from Vite's transformed CSS module code. + * Vite wraps CSS content in a JS module with __vite__css variable. + */ +function extractCssFromViteModule(code: string): string | undefined { + // Match: const __vite__css = "..." + const match = code.match(/const\s+__vite__css\s*=\s*["'`]([\s\S]*?)["'`]/) + if (match?.[1]) { + // Unescape the string + return match[1] + .replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\') + } + return undefined +} diff --git a/packages/start-plugin-core/src/dev-server-plugin/plugin.ts b/packages/start-plugin-core/src/dev-server-plugin/plugin.ts index fccbfb09de0..1fe8856310c 100644 --- a/packages/start-plugin-core/src/dev-server-plugin/plugin.ts +++ b/packages/start-plugin-core/src/dev-server-plugin/plugin.ts @@ -4,13 +4,14 @@ import { NodeRequest, sendNodeResponse } from 'srvx/node' import { ENTRY_POINTS, VITE_ENVIRONMENT_NAMES } from '../constants' import { resolveViteId } from '../utils' import { extractHtmlScripts } from './extract-html-scripts' +import { collectDevStyles } from './dev-styles' import type { Connect, DevEnvironment, PluginOption } from 'vite' -import type { TanStackStartOutputConfig } from '../schema' +import type { GetConfigFn } from '../types' export function devServerPlugin({ getConfig, }: { - getConfig: () => { startConfig: TanStackStartOutputConfig } + getConfig: GetConfigFn }): PluginOption { let isTest = false @@ -75,6 +76,52 @@ export function devServerPlugin({ ) } + // Middleware to serve collected CSS for dev mode + // Security: Route IDs from query params are validated against TSS_ROUTES_MANIFEST. + // Only routes that exist in the manifest will have their CSS collected. + // Arbitrary file paths cannot be injected. + viteDevServer.middlewares.use(async (req, res, next) => { + const url = req.url ?? '' + if (!url.startsWith('/@tanstack-start/styles.css')) { + return next() + } + + // Parse route IDs from query param + const urlObj = new URL(url, 'http://localhost') + const routesParam = urlObj.searchParams.get('routes') + const routeIds = routesParam ? routesParam.split(',') : [] + + // Build entries list from route file paths + const entries: Array = [] + + // Look up route file paths from manifest + // Only routes registered in the manifest are used - this prevents path injection + const routesManifest = (globalThis as any).TSS_ROUTES_MANIFEST as + | Record }> + | undefined + + if (routesManifest && routeIds.length > 0) { + for (const routeId of routeIds) { + const route = routesManifest[routeId] + if (route?.filePath) { + entries.push(route.filePath) + } + } + } + + const css = + entries.length > 0 + ? await collectDevStyles({ + viteDevServer, + entries, + }) + : undefined + + res.setHeader('Content-Type', 'text/css') + res.setHeader('Cache-Control', 'no-store') + res.end(css ?? '') + }) + viteDevServer.middlewares.use(async (req, res) => { // fix the request URL to match the original URL // otherwise, the request URL will '/index.html' diff --git a/packages/vue-router/src/HeadContent.tsx b/packages/vue-router/src/HeadContent.tsx index 1baeef6a1fc..a7666d20a7d 100644 --- a/packages/vue-router/src/HeadContent.tsx +++ b/packages/vue-router/src/HeadContent.tsx @@ -6,6 +6,41 @@ import { useRouter } from './useRouter' import { useRouterState } from './useRouterState' import type { RouterManagedTag } from '@tanstack/router-core' +/** + * Renders a stylesheet link for dev mode CSS collection. + * On the server, renders the full link with route-scoped CSS URL. + * On the client, renders the same link to avoid hydration mismatch, + * then removes it after hydration since Vite's HMR handles CSS updates. + */ +const DevStylesLink = Vue.defineComponent({ + name: 'DevStylesLink', + setup() { + const routeIds = useRouterState({ + select: (state) => state.matches.map((match) => match.routeId), + }) + + Vue.onMounted(() => { + // After hydration, remove the SSR-rendered dev styles link + document + .querySelectorAll('[data-tanstack-start-dev-styles]') + .forEach((el) => el.remove()) + }) + + // Build the same href on both server and client for hydration match + const href = Vue.computed( + () => + `/@tanstack-start/styles.css?routes=${encodeURIComponent(routeIds.value.join(','))}`, + ) + + return () => + Vue.h('link', { + rel: 'stylesheet', + href: href.value, + 'data-tanstack-start-dev-styles': true, + }) + }, +}) + export const useTags = () => { const router = useRouter() @@ -152,12 +187,19 @@ export const HeadContent = Vue.defineComponent({ const tags = useTags() return () => { - return tags().map((tag) => + const children = tags().map((tag) => Vue.h(Asset, { ...tag, key: `tsr-meta-${JSON.stringify(tag)}`, }), ) + + // In dev mode, prepend the DevStylesLink + if (process.env.NODE_ENV !== 'production') { + return [Vue.h(DevStylesLink), ...children] + } + + return children } }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18462b46d8f..57b57bc2d81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1516,6 +1516,52 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/css-modules: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + srvx: + specifier: ^0.10.0 + version: 0.10.0 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/custom-basepath: dependencies: '@tanstack/react-router': @@ -3352,6 +3398,43 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/solid-start/css-modules: + dependencies: + '@tanstack/solid-router': + specifier: workspace:^ + version: link:../../../packages/solid-router + '@tanstack/solid-start': + specifier: workspace:* + version: link:../../../packages/solid-start + solid-js: + specifier: 1.9.10 + version: 1.9.10 + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + srvx: + specifier: ^0.10.0 + version: 0.10.0 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vite-plugin-solid: + specifier: ^2.11.10 + version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.10)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/solid-start/custom-basepath: dependencies: '@tanstack/solid-router': @@ -5103,6 +5186,43 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/vue-start/css-modules: + dependencies: + '@tanstack/vue-router': + specifier: workspace:* + version: link:../../../packages/vue-router + '@tanstack/vue-start': + specifier: workspace:* + version: link:../../../packages/vue-start + vue: + specifier: ^3.5.16 + version: 3.5.25(typescript@5.9.2) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@vitejs/plugin-vue-jsx': + specifier: ^4.1.2 + version: 4.2.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.2)) + srvx: + specifier: ^0.10.0 + version: 0.10.0 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/vue-start/custom-basepath: dependencies: '@tanstack/vue-router': @@ -10380,7 +10500,7 @@ importers: devDependencies: '@netlify/vite-plugin-tanstack-start': specifier: ^1.1.4 - version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) @@ -26708,13 +26828,13 @@ snapshots: uuid: 11.1.0 write-file-atomic: 5.0.1 - '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)': + '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/config': 23.2.0 '@netlify/dev-utils': 4.3.0 '@netlify/edge-functions-dev': 1.0.0 - '@netlify/functions-dev': 1.0.0(rollup@4.52.5) + '@netlify/functions-dev': 1.0.0(encoding@0.1.13)(rollup@4.52.5) '@netlify/headers': 2.1.0 '@netlify/images': 1.3.0(@netlify/blobs@10.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0) '@netlify/redirects': 3.1.0 @@ -26782,12 +26902,12 @@ snapshots: dependencies: '@netlify/types': 2.1.0 - '@netlify/functions-dev@1.0.0(rollup@4.52.5)': + '@netlify/functions-dev@1.0.0(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/dev-utils': 4.3.0 '@netlify/functions': 5.0.0 - '@netlify/zip-it-and-ship-it': 14.1.11(rollup@4.52.5) + '@netlify/zip-it-and-ship-it': 14.1.11(encoding@0.1.13)(rollup@4.52.5) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -26877,9 +26997,9 @@ snapshots: '@netlify/types@2.1.0': {} - '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) optionalDependencies: '@tanstack/solid-start': link:packages/solid-start @@ -26907,9 +27027,9 @@ snapshots: - supports-color - uploadthing - '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5) + '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5) '@netlify/dev-utils': 4.3.0 dedent: 1.7.0(babel-plugin-macros@3.1.0) vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) @@ -26937,13 +27057,13 @@ snapshots: - supports-color - uploadthing - '@netlify/zip-it-and-ship-it@14.1.11(rollup@4.52.5)': + '@netlify/zip-it-and-ship-it@14.1.11(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.4 '@netlify/binary-info': 1.0.0 '@netlify/serverless-functions-api': 2.7.1 - '@vercel/nft': 0.29.4(rollup@4.52.5) + '@vercel/nft': 0.29.4(encoding@0.1.13)(rollup@4.52.5) archiver: 7.0.1 common-path-prefix: 3.0.0 copy-file: 11.1.0 @@ -30198,7 +30318,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/nft@0.29.4(rollup@4.52.5)': + '@vercel/nft@0.29.4(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@mapbox/node-pre-gyp': 2.0.0(encoding@0.1.13) '@rollup/pluginutils': 5.1.4(rollup@4.52.5)