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 @@ + + + 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() function isCssFile(file: string): boolean { return CSS_FILE_REGEX.test(file) } -export function isCssModulesFile(file: string): boolean { - return CSS_MODULES_REGEX.test(file) -} - function hasCssSideEffectFreeParam(url: string): boolean { const queryString = url.split('?')[1] if (!queryString) return false @@ -67,11 +56,15 @@ function resolveDevUrl(rootDirectory: string, filePath: string): string { return path.posix.join('/@fs', normalizedPath) } +export type DevStylesEnvironment = Pick & { + moduleGraph: Pick +} + export interface CollectDevStylesOptions { - viteDevServer: ViteDevServer + serverEnvironment: DevStylesEnvironment + rootDirectory: string entries: Array - /** Cache of CSS modules content captured during transform hook */ - cssModulesCache?: Record + loadCssContents: (url: string) => Promise } /** @@ -80,167 +73,195 @@ export interface CollectDevStylesOptions { export async function collectDevStyles( opts: CollectDevStylesOptions, ): Promise { - const { viteDevServer, entries, cssModulesCache = {} } = opts - const styles: Map = new Map() - const visited = new Set() - - const rootDirectory = viteDevServer.config.root + const { serverEnvironment, rootDirectory, entries, loadCssContents } = opts - // Process entries in parallel - each entry is independent - await Promise.all( - entries.map((entry) => - processEntry(viteDevServer, resolveDevUrl(rootDirectory, entry), visited), - ), + const orderedCssNodes = await collectCssNodes( + serverEnvironment, + entries.map((entry) => resolveDevUrl(rootDirectory, entry)), ) - // Collect CSS from visited modules in parallel - const cssPromises: Array> = [] + // Promise.all keeps graph order while loading the CSS concurrently. + const cssResults = await Promise.all( + orderedCssNodes.map((node) => loadCssContents(node.url)), + ) - for (const dep of visited) { - if (hasCssSideEffectFreeParam(dep.url)) { + const parts: Array = [] + for (let i = 0; i < cssResults.length; i++) { + const css = cssResults[i] + if (!css) { continue } + const escapedFileName = orderedCssNodes[i]!.url.replace( + ESCAPE_CSS_COMMENT_START_REGEX, + '/\\*', + ).replace(ESCAPE_CSS_COMMENT_END_REGEX, '*\\/') + parts.push(`\n/* ${escapedFileName} */\n${css}`) + } + return parts.length > 0 ? parts.join('\n') : undefined +} - if (dep.file && isCssModulesFile(dep.file)) { - const css = cssModulesCache[normalizeCssModuleCacheKey(dep.file)] - if (!css) { - // skip this module - it may not be a real CSS module or it may not have been transformed yet - continue - } - styles.set(dep.url, css) - continue +/** + * Resolve entry nodes and their dependency graph in parallel, then linearize the + * completed graph synchronously. This keeps I/O concurrent without making + * Promise resolution timing part of the CSS cascade order. + */ +async function collectCssNodes( + environment: DevStylesEnvironment, + entryUrls: Array, +): Promise> { + const entryNodes = await Promise.all( + entryUrls.map((entryUrl) => resolveEntry(environment, entryUrl)), + ) + const dependencies = new Map< + EnvironmentModuleNode, + ReadonlyArray + >() + const discovered = new Set() + const dependencyLookups = new Map< + string, + Promise + >() + + function getDependency( + url: string, + ): Promise { + let lookup = dependencyLookups.get(url) + if (!lookup) { + lookup = environment.moduleGraph.getModuleByUrl(url) + dependencyLookups.set(url, lookup) } + return lookup + } - const fileOrUrl = dep.file ?? dep.url - if (!isCssFile(fileOrUrl)) { - continue + async function discover(node: EnvironmentModuleNode): Promise { + if (discovered.has(node)) { + return } + discovered.add(node) - // Load regular CSS files in parallel - cssPromises.push( - fetchCssFromModule(viteDevServer, dep).then((css) => - css ? ([dep.url, css] as const) : null, - ), - ) + const nodeDependencies = await resolveModuleDeps(node, getDependency) + dependencies.set(node, nodeDependencies) + await Promise.all(nodeDependencies.map(discover)) } - // Wait for all CSS loads to complete - const cssResults = await Promise.all(cssPromises) - for (const result of cssResults) { - if (result) { - styles.set(result[0], result[1]) - } - } + await Promise.all( + entryNodes.map((node) => (node ? discover(node) : Promise.resolve())), + ) - if (styles.size === 0) return undefined + const orderedCssNodes: Array = [] + const ordered = new Set() + const stack = [...entryNodes].reverse() - const parts: Array = [] - for (const [fileName, css] of styles.entries()) { - const escapedFileName = fileName - .replace(ESCAPE_CSS_COMMENT_START_REGEX, '/\\*') - .replace(ESCAPE_CSS_COMMENT_END_REGEX, '*\\/') - parts.push(`\n/* ${escapedFileName} */\n${css}`) + while (stack.length > 0) { + const node = stack.pop() + if (!node || ordered.has(node)) { + continue + } + ordered.add(node) + if (isCssFile(node.url) && !hasCssSideEffectFreeParam(node.url)) { + orderedCssNodes.push(node) + } + + const nodeDependencies = dependencies.get(node) ?? [] + for (let i = nodeDependencies.length - 1; i >= 0; i--) { + stack.push(nodeDependencies[i]) + } } - return parts.join('\n') + + return orderedCssNodes } -/** - * Process an entry URL: transform it if needed, get the module node, and crawl its dependencies. - */ -async function processEntry( - viteDevServer: ViteDevServer, +async function resolveEntry( + environment: DevStylesEnvironment, entryUrl: string, - visited: Set, -): Promise { - let node = await viteDevServer.moduleGraph.getModuleByUrl(entryUrl) +): Promise { + let node = await environment.moduleGraph.getModuleByUrl(entryUrl) - // Only transform if not yet SSR-transformed (need ssrTransformResult.deps for crawling) - if (!node?.ssrTransformResult) { + // Only transform if needed so transformResult.deps is available for crawling. + if (!node?.transformResult) { try { - await viteDevServer.transformRequest(entryUrl) + await environment.transformRequest(entryUrl) } catch { // ignore - module might not exist yet } - node = await viteDevServer.moduleGraph.getModuleByUrl(entryUrl) + node = await environment.moduleGraph.getModuleByUrl(entryUrl) } - if (!node || visited.has(node)) return - - visited.add(node) - await findModuleDeps(viteDevServer, node, visited) + return node } /** - * Find all module dependencies by crawling the module graph. - * Uses transformResult.deps for URL-based lookups (parallel) and - * importedModules for already-resolved nodes (parallel). + * Resolve a module's direct dependencies in graph order. + * Uses transformResult.deps for URL-based lookups and importedModules for + * already-resolved nodes. */ -async function findModuleDeps( - viteDevServer: ViteDevServer, - node: ModuleNode, - visited: Set, -): Promise { - // Note: caller must add node to visited BEFORE calling this function - // to prevent race conditions with parallel traversal - - // Process deps from transformResult if available (URLs including bare imports) - const deps = - node.ssrTransformResult?.deps ?? node.transformResult?.deps ?? null +async function resolveModuleDeps( + node: EnvironmentModuleNode, + getDependency: (url: string) => Promise, +): Promise> { + // Vite's client transform already includes CSS @imports in the parent CSS. + // Following either dependency source here would append the imported CSS again. + if (isCssFile(node.url)) { + return NO_DEPENDENCIES + } - const importedModules = node.importedModules + const depUrls = node.transformResult?.deps ?? [] + const importedModules = + node.importedModules.size > 0 + ? Array.from(node.importedModules) + : NO_DEPENDENCIES // Fast path: no deps and no imports - if ((!deps || deps.length === 0) && importedModules.size === 0) { - return + if (depUrls.length === 0 && importedModules.length === 0) { + return NO_DEPENDENCIES } - // Build branches only when needed (avoid array allocation on leaf nodes) - const branches: Array> = [] - - if (deps) { - for (const depUrl of deps) { - const dep = await viteDevServer.moduleGraph.getModuleByUrl(depUrl) - if (!dep) continue + const resolvedDeps = await Promise.all(depUrls.map(getDependency)) + const orderedDependencies: Array = [] + const added = new Set() - if (visited.has(dep)) continue - visited.add(dep) - branches.push(findModuleDeps(viteDevServer, dep, visited)) + for (const dependency of resolvedDeps) { + if (dependency && !added.has(dependency)) { + added.add(dependency) + orderedDependencies.push(dependency) } } - // ALWAYS also traverse importedModules - this catches: - // - Code-split chunks (e.g. ?tsr-split=component) not in deps - // - Already-resolved nodes - for (const depNode of importedModules) { - if (visited.has(depNode)) continue - visited.add(depNode) - branches.push(findModuleDeps(viteDevServer, depNode, visited)) - } - - if (branches.length === 1) { - await branches[0] - return + // Also traverse importedModules for non-CSS modules. This catches + // code-split chunks (e.g. ?tsr-split=component) that are not in deps, as + // well as already-resolved nodes. Vite inlines CSS @imports into the + // transformed parent stylesheet, so traversing a CSS module's importedModules + // would collect and append those stylesheets a second time. + for (const dependency of importedModules) { + if (!added.has(dependency)) { + added.add(dependency) + orderedDependencies.push(dependency) + } } - await Promise.all(branches) + return orderedDependencies } -async function fetchCssFromModule( - viteDevServer: ViteDevServer, - node: ModuleNode, +export async function fetchCssFromModule( + environment: DevStylesEnvironment, + url: string, ): Promise { - // Use cached transform result if available - const cachedCode = node.transformResult?.code ?? node.ssrTransformResult?.code - if (cachedCode) { - return extractCssFromCode(cachedCode) + // Vite embeds the CSS text in the client environment's transform result. + // The SSR environment is only used for dependency discovery. + const node = await environment.moduleGraph.getModuleByUrl(url) + const cachedTransform = node?.transformResult + if (cachedTransform?.code) { + return extractCssFromTransform(cachedTransform) } // Otherwise request a fresh transform try { - const transformResult = await viteDevServer.transformRequest(node.url) - if (!transformResult?.code) return undefined + const transformResult = await environment.transformRequest(url) + if (!transformResult?.code) { + return undefined + } - return extractCssFromCode(transformResult.code) + return extractCssFromTransform(transformResult) } catch { // Preprocessor partials (e.g., Sass files with mixins) can't compile in isolation. // The root stylesheet that @imports them will contain the compiled CSS. @@ -248,6 +269,19 @@ async function fetchCssFromModule( } } +function extractCssFromTransform(transformResult: { + code: string +}): string | undefined { + const cachedCss = cssTransformCache.get(transformResult) + if (cachedCss !== undefined) { + return cachedCss ?? undefined + } + + const css = extractCssFromCode(transformResult.code) + cssTransformCache.set(transformResult, css ?? null) + return css +} + /** * Extract CSS content from Vite's transformed CSS module code. * diff --git a/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts b/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts index 18d3e54e2e..97d26bb41b 100644 --- a/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts @@ -1,11 +1,7 @@ import { isRunnableDevEnvironment } from 'vite' import { NodeRequest, sendNodeResponse } from 'srvx/node' import { ENTRY_POINTS, VITE_ENVIRONMENT_NAMES } from '../../constants' -import { - CSS_MODULES_REGEX, - collectDevStyles, - normalizeCssModuleCacheKey, -} from './dev-styles' +import { collectDevStyles, fetchCssFromModule } from './dev-styles' import type { Connect, DevEnvironment, PluginOption } from 'vite' import type { GetConfigFn } from '../../types' @@ -20,26 +16,12 @@ export function devServerPlugin({ }): PluginOption { let isTest = false - // Cache CSS modules content during transform hook. - // For CSS modules, the transform hook receives the raw CSS content before - // Vite wraps it in JS. We capture this to use during SSR style collection. - const cssModulesCache: Record = {} - return [ { name: 'tanstack-start-core:dev-server', config(_userConfig, { mode }) { isTest = isTest ? isTest : mode === 'test' }, - // Capture CSS modules content during transform - transform: { - filter: { - id: CSS_MODULES_REGEX, - }, - handler(code, id) { - cssModulesCache[normalizeCssModuleCacheKey(id)] = code - }, - }, configureServer(viteDevServer) { if (isTest) { return @@ -86,9 +68,19 @@ export function devServerPlugin({ const css = entries.length > 0 ? await collectDevStyles({ - viteDevServer, + serverEnvironment: + viteDevServer.environments[ + VITE_ENVIRONMENT_NAMES.server + ], + rootDirectory: viteDevServer.config.root, entries, - cssModulesCache, + loadCssContents: (url) => + fetchCssFromModule( + viteDevServer.environments[ + VITE_ENVIRONMENT_NAMES.client + ], + url, + ), }) : undefined diff --git a/packages/start-plugin-core/tests/vite/dev-styles.test.ts b/packages/start-plugin-core/tests/vite/dev-styles.test.ts new file mode 100644 index 0000000000..da355b74c2 --- /dev/null +++ b/packages/start-plugin-core/tests/vite/dev-styles.test.ts @@ -0,0 +1,644 @@ +import { describe, expect, test } from 'vitest' +import { + collectDevStyles, + fetchCssFromModule, +} from '../../src/vite/dev-server-plugin/dev-styles' +import type { EnvironmentModuleNode } from 'vite' + +function createCssNode(url: string, css: string): EnvironmentModuleNode { + return { + url, + file: `/app${url}`, + importedModules: new Set(), + transformResult: { + code: `const __vite__css = ${JSON.stringify(css)}`, + }, + } as EnvironmentModuleNode +} + +function createEntryNode( + url: string, + depUrl: string | Array, +): EnvironmentModuleNode { + return { + url, + file: `/app${url}`, + importedModules: new Set(), + transformResult: { + deps: Array.isArray(depUrl) ? depUrl : [depUrl], + }, + } as EnvironmentModuleNode +} + +describe('collectDevStyles', () => { + test('recognizes virtual Vue style blocks by URL', async () => { + const styleUrl = '/src/App.vue?vue&type=style&index=0&lang.css' + const entry = createEntryNode('/src/entry.tsx', styleUrl) + const vueStyle = createCssNode(styleUrl, '.vue-style {}') + vueStyle.file = '/app/src/App.vue' + const importedCss = createCssNode('/src/imported.css', '.imported {}') + vueStyle.transformResult!.deps = [importedCss.url] + vueStyle.importedModules.add(importedCss) + const modules = new Map( + [entry, vueStyle, importedCss].map((node) => [node.url, node]), + ) + const environment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return modules.get(url) + }, + }, + async transformRequest() { + return null + }, + } + + const css = await collectDevStyles({ + serverEnvironment: environment, + rootDirectory: '/app', + entries: ['/app/src/entry.tsx'], + loadCssContents: (url) => fetchCssFromModule(environment, url), + }) + + expect(css).toContain(`/* ${styleUrl} */`) + expect(css).toContain('.vue-style {}') + expect(css).not.toContain('/* /src/imported.css */') + }) + + test('does not traverse dependencies of CSS files', async () => { + const entry = createEntryNode('/src/entry.tsx', '/src/importer.css') + const importerCss = createCssNode( + '/src/importer.css', + '.dep-imported {}\n.module-imported {}\n.importer {}', + ) + const depImportedCss = createCssNode( + '/src/dep-imported.css', + '.dep-imported {}', + ) + const moduleImportedCss = createCssNode( + '/src/module-imported.css', + '.module-imported {}', + ) + importerCss.transformResult!.deps = [depImportedCss.url] + importerCss.importedModules.add(moduleImportedCss) + const modules = new Map( + [entry, importerCss, depImportedCss, moduleImportedCss].map((node) => [ + node.url, + node, + ]), + ) + const environment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return modules.get(url) + }, + }, + async transformRequest() { + return null + }, + } + + const css = await collectDevStyles({ + serverEnvironment: environment, + rootDirectory: '/app', + entries: ['/app/src/entry.tsx'], + loadCssContents: (url) => fetchCssFromModule(environment, url), + }) + + expect(css).toContain('/* /src/importer.css */') + expect(css).not.toContain('/* /src/dep-imported.css */') + expect(css).not.toContain('/* /src/module-imported.css */') + }) + + test('loads CSS modules from the client environment', async () => { + const serverEntry = createEntryNode( + '/src/entry.tsx', + '/src/route.module.css', + ) + const serverCssModule = createCssNode( + '/src/route.module.css', + '.server-transform {}', + ) + const clientCssModule = createCssNode( + '/src/route.module.css', + '.client-transform {}', + ) + const serverModules = new Map( + [serverEntry, serverCssModule].map((node) => [node.url, node]), + ) + const clientModules = new Map([[clientCssModule.url, clientCssModule]]) + const serverEnvironment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return serverModules.get(url) + }, + }, + async transformRequest() { + return null + }, + } + const clientEnvironment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return clientModules.get(url) + }, + }, + async transformRequest() { + return null + }, + } + + const css = await collectDevStyles({ + serverEnvironment, + rootDirectory: '/app', + entries: ['/app/src/entry.tsx'], + loadCssContents: (url) => fetchCssFromModule(clientEnvironment, url), + }) + + expect(css).toContain('.client-transform {}') + expect(css).not.toContain('.server-transform {}') + }) + + test('traverses code-split modules that are only in importedModules', async () => { + const entry = createEntryNode('/src/entry.tsx', []) + const splitModule = createEntryNode( + '/src/entry.tsx?tsr-split=component', + '/src/code-split.css', + ) + const splitCss = createCssNode('/src/code-split.css', '.code-split {}') + entry.importedModules.add(splitModule) + const modules = new Map( + [entry, splitModule, splitCss].map((node) => [node.url, node]), + ) + const environment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return modules.get(url) + }, + }, + async transformRequest() { + return null + }, + } + + const css = await collectDevStyles({ + serverEnvironment: environment, + rootDirectory: '/app', + entries: ['/app/src/entry.tsx'], + loadCssContents: (url) => fetchCssFromModule(environment, url), + }) + + expect(css).toContain('/* /src/code-split.css */') + expect(css?.match(/\.code-split \{\}/g)).toHaveLength(1) + }) + + test('preserves entry order when graph lookups finish out of order', async () => { + const rootEntry = createEntryNode('/src/root.tsx', '/src/root.css') + const routeEntry = createEntryNode('/src/route.tsx', '/src/route.css') + const rootCss = createCssNode('/src/root.css', '.root {}') + const routeCss = createCssNode('/src/route.css', '.route {}') + + const modules = new Map( + [rootEntry, routeEntry, rootCss, routeCss].map((node) => [ + node.url, + node, + ]), + ) + const moduleGraph = { + async getModuleByUrl(url: string) { + if (url === rootEntry.url) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + return modules.get(url) + }, + } + const environment = { + moduleGraph, + async transformRequest() { + return null + }, + } + const css = await collectDevStyles({ + serverEnvironment: environment, + rootDirectory: '/app', + entries: ['/app/src/root.tsx', '/app/src/route.tsx'], + loadCssContents: (url) => fetchCssFromModule(environment, url), + }) + + expect(css).toBeDefined() + expect(css!.indexOf('/* /src/root.css */')).toBeLessThan( + css!.indexOf('/* /src/route.css */'), + ) + }) + + test('preserves dependency order while resolving dependencies in parallel', async () => { + const entry = createEntryNode('/src/entry.tsx', [ + '/src/first.css', + '/src/second.css', + ]) + const firstCss = createCssNode('/src/first.css', '.first {}') + const secondCss = createCssNode('/src/second.css', '.second {}') + const modules = new Map( + [entry, firstCss, secondCss].map((node) => [node.url, node]), + ) + + let firstLookupFinished = false + let secondStartedBeforeFirstFinished = false + const moduleGraph = { + async getModuleByUrl(url: string) { + if (url === firstCss.url && !firstLookupFinished) { + await new Promise((resolve) => setTimeout(resolve, 20)) + firstLookupFinished = true + } + if (url === secondCss.url && !firstLookupFinished) { + secondStartedBeforeFirstFinished = true + } + return modules.get(url) + }, + } + const environment = { + moduleGraph, + async transformRequest() { + return null + }, + } + const css = await collectDevStyles({ + serverEnvironment: environment, + rootDirectory: '/app', + entries: ['/app/src/entry.tsx'], + loadCssContents: (url) => fetchCssFromModule(environment, url), + }) + + expect(secondStartedBeforeFirstFinished).toBe(true) + expect(css).toContain('/* /src/first.css */') + expect(css).toContain('/* /src/second.css */') + expect(css!.indexOf('/* /src/first.css */')).toBeLessThan( + css!.indexOf('/* /src/second.css */'), + ) + }) + + test('preserves CSS order while loading regular and module CSS in parallel', async () => { + const entry = createEntryNode('/src/entry.tsx', [ + '/src/root.css', + '/src/route.module.css', + ]) + const rootCss = createCssNode('/src/root.css', '') + const routeCss = createCssNode('/src/route.module.css', '') + const modules = new Map( + [entry, rootCss, routeCss].map((node) => [node.url, node]), + ) + const environment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return modules.get(url) + }, + }, + async transformRequest() { + return null + }, + } + let rootFinished = false + let routeStartedBeforeRootFinished = false + + const css = await collectDevStyles({ + serverEnvironment: environment, + rootDirectory: '/app', + entries: ['/app/src/entry.tsx'], + loadCssContents: async (url) => { + if (url === rootCss.url) { + await new Promise((resolve) => setTimeout(resolve, 20)) + rootFinished = true + return '.root {}' + } + if (!rootFinished) { + routeStartedBeforeRootFinished = true + } + return '.route {}' + }, + }) + + expect(routeStartedBeforeRootFinished).toBe(true) + expect(css!.indexOf('/* /src/root.css */')).toBeLessThan( + css!.indexOf('/* /src/route.module.css */'), + ) + }) + + test('handles cycles and coalesces shared dependency lookups', async () => { + const firstEntry = createEntryNode('/src/first.tsx', [ + '/src/first.css', + 'external-package', + '/src/shared.css', + ]) + const secondEntry = createEntryNode('/src/second.tsx', [ + '/src/second.css', + 'external-package', + '/src/shared.css', + ]) + const firstCss = createCssNode('/src/first.css', '.first {}') + const secondCss = createCssNode('/src/second.css', '.second {}') + const sharedCss = createCssNode('/src/shared.css', '.shared {}') + firstEntry.importedModules.add(secondEntry) + secondEntry.importedModules.add(firstEntry) + const modules = new Map( + [firstEntry, secondEntry, firstCss, secondCss, sharedCss].map((node) => [ + node.url, + node, + ]), + ) + const lookupCounts = new Map() + const environment = { + moduleGraph: { + async getModuleByUrl(url: string) { + lookupCounts.set(url, (lookupCounts.get(url) ?? 0) + 1) + return modules.get(url) + }, + }, + async transformRequest() { + return null + }, + } + + const css = await collectDevStyles({ + serverEnvironment: environment, + rootDirectory: '/app', + entries: ['/app/src/first.tsx', '/app/src/second.tsx'], + loadCssContents: async (url) => `.${url.split('/').at(-1)} {}`, + }) + + expect(lookupCounts.get('external-package')).toBe(1) + expect(lookupCounts.get('/src/shared.css')).toBe(1) + expect(css?.match(/\/\* \/src\/shared\.css \*\//g)).toHaveLength(1) + expect(css!.indexOf('/* /src/first.css */')).toBeLessThan( + css!.indexOf('/* /src/shared.css */'), + ) + expect(css!.indexOf('/* /src/shared.css */')).toBeLessThan( + css!.indexOf('/* /src/second.css */'), + ) + }) + + test('refreshes the graph and client CSS after invalidation', async () => { + const entry = createEntryNode('/src/entry.tsx', '/src/old.css') + const oldServerCss = createCssNode('/src/old.css', '') + const newServerCss = createCssNode('/src/new.css', '') + const oldClientCss = createCssNode('/src/old.css', '.old {}') + const newClientCss = createCssNode('/src/new.css', '.new {}') + const newClientTransform = newClientCss.transformResult! + newClientCss.transformResult = null + + const serverModules = new Map( + [entry, oldServerCss, newServerCss].map((node) => [node.url, node]), + ) + const clientModules = new Map( + [oldClientCss, newClientCss].map((node) => [node.url, node]), + ) + const serverEnvironment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return serverModules.get(url) + }, + }, + async transformRequest(url: string) { + if (url === entry.url) { + entry.transformResult = { + code: '', + map: null, + deps: [newServerCss.url], + } + } + return entry.transformResult + }, + } + const clientEnvironment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return clientModules.get(url) + }, + }, + async transformRequest(url: string) { + if (url === newClientCss.url) { + newClientCss.transformResult = newClientTransform + return newClientTransform + } + return null + }, + } + const options = { + serverEnvironment, + rootDirectory: '/app', + entries: ['/app/src/entry.tsx'], + loadCssContents: (url: string) => + fetchCssFromModule(clientEnvironment, url), + } + + const initialCss = await collectDevStyles(options) + entry.transformResult = null + const invalidatedCss = await collectDevStyles(options) + + expect(initialCss).toContain('.old {}') + expect(invalidatedCss).toContain('.new {}') + expect(invalidatedCss).not.toContain('.old {}') + }) + + test('refreshes cached CSS when the same URL is invalidated', async () => { + const entry = createEntryNode('/src/entry.tsx', '/src/route.css') + const serverCss = createCssNode('/src/route.css', '') + const clientCss = createCssNode('/src/route.css', '.old {}') + const serverModules = new Map( + [entry, serverCss].map((node) => [node.url, node]), + ) + const clientModules = new Map([[clientCss.url, clientCss]]) + const newTransform = { + code: `const __vite__css = ${JSON.stringify('.new {}')}`, + map: null, + } + const serverEnvironment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return serverModules.get(url) + }, + }, + async transformRequest() { + return null + }, + } + const clientEnvironment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return clientModules.get(url) + }, + }, + async transformRequest(url: string) { + if (url === clientCss.url) { + clientCss.transformResult = newTransform + return newTransform + } + return null + }, + } + const options = { + serverEnvironment, + rootDirectory: '/app', + entries: ['/app/src/entry.tsx'], + loadCssContents: (url: string) => + fetchCssFromModule(clientEnvironment, url), + } + + const initialCss = await collectDevStyles(options) + clientCss.transformResult = null + const invalidatedCss = await collectDevStyles(options) + + expect(initialCss).toContain('.old {}') + expect(invalidatedCss).toContain('.new {}') + expect(invalidatedCss).not.toContain('.old {}') + }) + + test('snapshots importedModules before awaiting dependency lookups', async () => { + const entry = createEntryNode('/src/entry.tsx', 'slow-dependency') + const initialCss = createCssNode('/src/initial.css', '.initial {}') + const lateCss = createCssNode('/src/late.css', '.late {}') + entry.importedModules.add(initialCss) + const modules = new Map( + [entry, initialCss, lateCss].map((node) => [node.url, node]), + ) + let notifyLookupStarted!: () => void + let finishLookup!: () => void + const lookupStarted = new Promise((resolve) => { + notifyLookupStarted = resolve + }) + const lookupCanFinish = new Promise((resolve) => { + finishLookup = resolve + }) + const environment = { + moduleGraph: { + async getModuleByUrl(url: string) { + if (url === 'slow-dependency') { + notifyLookupStarted() + await lookupCanFinish + } + return modules.get(url) + }, + }, + async transformRequest() { + return null + }, + } + const options = { + serverEnvironment: environment, + rootDirectory: '/app', + entries: ['/app/src/entry.tsx'], + loadCssContents: (url: string) => fetchCssFromModule(environment, url), + } + + const inFlightCollection = collectDevStyles(options) + await lookupStarted + entry.importedModules.add(lateCss) + finishLookup() + const inFlightCss = await inFlightCollection + const nextCss = await collectDevStyles(options) + + expect(inFlightCss).toContain('.initial {}') + expect(inFlightCss).not.toContain('.late {}') + expect(nextCss).toContain('.late {}') + }) + + test('keeps other styles when a CSS transform fails', async () => { + const entry = createEntryNode('/src/entry.tsx', [ + '/src/good.css', + '/src/partial.scss', + ]) + const goodServerCss = createCssNode('/src/good.css', '') + const partialServerCss = createCssNode('/src/partial.scss', '') + const goodClientCss = createCssNode('/src/good.css', '.good {}') + const partialClientCss = createCssNode('/src/partial.scss', '') + partialClientCss.transformResult = null + const serverModules = new Map( + [entry, goodServerCss, partialServerCss].map((node) => [node.url, node]), + ) + const clientModules = new Map( + [goodClientCss, partialClientCss].map((node) => [node.url, node]), + ) + const serverEnvironment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return serverModules.get(url) + }, + }, + async transformRequest() { + return null + }, + } + const clientEnvironment = { + moduleGraph: { + async getModuleByUrl(url: string) { + return clientModules.get(url) + }, + }, + async transformRequest(url: string) { + if (url === partialClientCss.url) { + throw new Error('Sass partial cannot compile in isolation') + } + return null + }, + } + + const css = await collectDevStyles({ + serverEnvironment, + rootDirectory: '/app', + entries: ['/app/src/entry.tsx'], + loadCssContents: (url) => fetchCssFromModule(clientEnvironment, url), + }) + + expect(css).toContain('.good {}') + expect(css).not.toContain('partial.scss') + }) + + test('does not traverse modules that exist only in the client graph', async () => { + const ssrRoot = createEntryNode('/src/root.tsx', '/src/root.css') + const ssrRootCss = createCssNode('/src/root.css', '') + const ssrModules = new Map( + [ssrRoot, ssrRootCss].map((node) => [node.url, node]), + ) + + const clientRoot = createEntryNode('/src/root.tsx', '/src/root.css') + const clientRootCss = createCssNode('/src/root.css', '.root {}') + const clientOnlyModule = createEntryNode( + '/src/client-only.ts', + '/src/client-only.css', + ) + const clientOnlyCss = createCssNode( + '/src/client-only.css', + '.client-only {}', + ) + clientRoot.importedModules.add(clientOnlyModule) + const clientModules = new Map( + [clientRoot, clientRootCss, clientOnlyModule, clientOnlyCss].map( + (node) => [node.url, node], + ), + ) + + const createEnvironment = ( + modules: Map, + ) => ({ + moduleGraph: { + async getModuleByUrl(url: string) { + return modules.get(url) + }, + }, + async transformRequest() { + return null + }, + }) + const clientEnvironment = createEnvironment(clientModules) + const ssrEnvironment = createEnvironment(ssrModules) + + const css = await collectDevStyles({ + serverEnvironment: ssrEnvironment, + rootDirectory: '/app', + entries: ['/app/src/root.tsx'], + loadCssContents: (url) => fetchCssFromModule(clientEnvironment, url), + }) + + expect(css).toContain('.root {}') + expect(css).not.toContain('.client-only {}') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a91aaea20f..01da75588a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4763,6 +4763,43 @@ importers: specifier: ^2.11.11 version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + e2e/solid-start/dev-ssr-styles: + 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.12 + version: 1.9.12 + devDependencies: + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.1 + '@tanstack/router-core': + specifier: workspace:* + version: link:../../../packages/router-core + '@tanstack/router-e2e-utils': + specifier: workspace:* + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + srvx: + specifier: ^0.11.9 + version: 0.11.15 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + vite-plugin-solid: + specifier: ^2.11.11 + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + e2e/solid-start/query-integration: dependencies: '@tanstack/solid-query': @@ -6577,6 +6614,46 @@ importers: specifier: ^8.0.14 version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/vue-start/dev-ssr-styles: + 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@6.0.2) + devDependencies: + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.1 + '@tanstack/router-core': + specifier: workspace:* + version: link:../../../packages/router-core + '@tanstack/router-e2e-utils': + specifier: workspace:* + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + '@vitejs/plugin-vue': + specifier: ^6.0.5 + version: 6.0.5(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@6.0.2)) + '@vitejs/plugin-vue-jsx': + specifier: ^5.1.5 + version: 5.1.5(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@6.0.2)) + srvx: + specifier: ^0.11.9 + version: 0.11.15 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/vue-start/query-integration: dependencies: '@tanstack/vue-query':