diff --git a/e2e/react-start/css-modules/.gitignore b/e2e/react-start/css-modules/.gitignore index 950326609c..229709a89d 100644 --- a/e2e/react-start/css-modules/.gitignore +++ b/e2e/react-start/css-modules/.gitignore @@ -1,7 +1,5 @@ node_modules dist -.routeTree.gen.ts -src/routeTree.gen.ts test-results playwright-report port*.txt diff --git a/e2e/react-start/css-modules/src/routeTree.gen.ts b/e2e/react-start/css-modules/src/routeTree.gen.ts new file mode 100644 index 0000000000..d19ae80e5a --- /dev/null +++ b/e2e/react-start/css-modules/src/routeTree.gen.ts @@ -0,0 +1,122 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as SassMixinRouteImport } from './routes/sass-mixin' +import { Route as QuotesRouteImport } from './routes/quotes' +import { Route as ModulesRouteImport } from './routes/modules' +import { Route as IndexRouteImport } from './routes/index' + +const SassMixinRoute = SassMixinRouteImport.update({ + id: '/sass-mixin', + path: '/sass-mixin', + getParentRoute: () => rootRouteImport, +} as any) +const QuotesRoute = QuotesRouteImport.update({ + id: '/quotes', + path: '/quotes', + getParentRoute: () => rootRouteImport, +} as any) +const ModulesRoute = ModulesRouteImport.update({ + id: '/modules', + path: '/modules', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/modules': typeof ModulesRoute + '/quotes': typeof QuotesRoute + '/sass-mixin': typeof SassMixinRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/modules': typeof ModulesRoute + '/quotes': typeof QuotesRoute + '/sass-mixin': typeof SassMixinRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/modules': typeof ModulesRoute + '/quotes': typeof QuotesRoute + '/sass-mixin': typeof SassMixinRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/modules' | '/quotes' | '/sass-mixin' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/modules' | '/quotes' | '/sass-mixin' + id: '__root__' | '/' | '/modules' | '/quotes' | '/sass-mixin' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ModulesRoute: typeof ModulesRoute + QuotesRoute: typeof QuotesRoute + SassMixinRoute: typeof SassMixinRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/sass-mixin': { + id: '/sass-mixin' + path: '/sass-mixin' + fullPath: '/sass-mixin' + preLoaderRoute: typeof SassMixinRouteImport + parentRoute: typeof rootRouteImport + } + '/quotes': { + id: '/quotes' + path: '/quotes' + fullPath: '/quotes' + preLoaderRoute: typeof QuotesRouteImport + parentRoute: typeof rootRouteImport + } + '/modules': { + id: '/modules' + path: '/modules' + fullPath: '/modules' + preLoaderRoute: typeof ModulesRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ModulesRoute: ModulesRoute, + QuotesRoute: QuotesRoute, + SassMixinRoute: SassMixinRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/css-modules/src/routes/__root.tsx b/e2e/react-start/css-modules/src/routes/__root.tsx index 919112a8b3..d7840bc0c1 100644 --- a/e2e/react-start/css-modules/src/routes/__root.tsx +++ b/e2e/react-start/css-modules/src/routes/__root.tsx @@ -55,6 +55,13 @@ function RootComponent() { > Sass Mixin + + Quoted CSS +
diff --git a/e2e/react-start/css-modules/src/routes/quotes.tsx b/e2e/react-start/css-modules/src/routes/quotes.tsx new file mode 100644 index 0000000000..b5cdc02860 --- /dev/null +++ b/e2e/react-start/css-modules/src/routes/quotes.tsx @@ -0,0 +1,23 @@ +import { createFileRoute } from '@tanstack/react-router' +import '~/styles/quotes.css' + +export const Route = createFileRoute('/quotes')({ + component: Quotes, +}) + +function Quotes() { + return ( +
+

CSS Collection Test - Quoted Content

+

This page tests that CSS with quoted content is properly extracted.

+ +
+ This element uses CSS with content: "..." property +
+ +
+ This element's styles come after the quoted content - should still work +
+
+ ) +} diff --git a/e2e/react-start/css-modules/src/styles/quotes.css b/e2e/react-start/css-modules/src/styles/quotes.css new file mode 100644 index 0000000000..9147c97c2c --- /dev/null +++ b/e2e/react-start/css-modules/src/styles/quotes.css @@ -0,0 +1,17 @@ +/* CSS file with quotes that could break regex extraction */ + +.quote-test { + /* Using content with quotes - common pattern that could break extraction */ + content: 'Hello World'; + background-color: #ef4444; /* red-500 */ + padding: 20px; + border-radius: 8px; + color: white; +} + +.after-quote { + /* This style comes after quoted content - should still be present */ + background-color: #f59e0b; /* amber-500 */ + padding: 16px; + font-weight: bold; +} diff --git a/e2e/react-start/css-modules/tests/css.spec.ts b/e2e/react-start/css-modules/tests/css.spec.ts index ba876cc2ae..1df98767ab 100644 --- a/e2e/react-start/css-modules/tests/css.spec.ts +++ b/e2e/react-start/css-modules/tests/css.spec.ts @@ -151,6 +151,34 @@ test.describe('CSS styles in SSR (dev mode)', () => { ) expect(padding).toBe('24px') }) + + test('CSS with quoted content is fully extracted', async ({ + page, + baseURL, + }) => { + await page.goto(buildUrl(baseURL!, '/quotes')) + + // Verify the element using CSS with content:"..." is styled + const quoteElement = page.getByTestId('quote-styled') + await expect(quoteElement).toBeVisible() + + // #ef4444 (red-500) in RGB is rgb(239, 68, 68) + const quoteBackgroundColor = await quoteElement.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(quoteBackgroundColor).toBe('rgb(239, 68, 68)') + + // Verify styles AFTER the quoted content are also extracted + // This is the key test - the regex bug would cut off CSS at the first quote + const afterQuoteElement = page.getByTestId('after-quote-styled') + await expect(afterQuoteElement).toBeVisible() + + // #f59e0b (amber-500) in RGB is rgb(245, 158, 11) + const afterQuoteBackgroundColor = await afterQuoteElement.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(afterQuoteBackgroundColor).toBe('rgb(245, 158, 11)') + }) }) test('styles persist after hydration', async ({ page, baseURL }) => { 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 index e3d0943915..f3a4438584 100644 --- a/packages/start-plugin-core/src/dev-server-plugin/dev-styles.ts +++ b/packages/start-plugin-core/src/dev-server-plugin/dev-styles.ts @@ -20,7 +20,8 @@ export function normalizeCssModuleCacheKey(idOrFile: string): string { // URL params that indicate CSS should not be injected (e.g., ?url, ?inline) const CSS_SIDE_EFFECT_FREE_PARAMS = ['url', 'inline', 'raw', 'inline-css'] -const VITE_CSS_REGEX = /const\s+__vite__css\s*=\s*["'`]([\s\S]*?)["'`]/ +// Marker to find the CSS string in Vite's transformed output +const VITE_CSS_MARKER = 'const __vite__css = ' const ESCAPE_CSS_COMMENT_START_REGEX = /\/\*/g const ESCAPE_CSS_COMMENT_END_REGEX = /\*\//g @@ -248,13 +249,43 @@ async function fetchCssFromModule( } } -function extractCssFromCode(code: string): string | undefined { - const match = VITE_CSS_REGEX.exec(code) - if (!match?.[1]) return undefined +/** + * Extract CSS content from Vite's transformed CSS module code. + * + * Vite embeds CSS into the module as a JS string via `JSON.stringify(cssContent)`, + * e.g. `const __vite__css = ${JSON.stringify('...css...')}`. + * + * We locate that JSON string literal and run `JSON.parse` on it to reverse the + * escaping (\\n, \\t, \\", \\\\, \\uXXXX, etc.). + */ +export function extractCssFromCode(code: string): string | undefined { + const startIdx = code.indexOf(VITE_CSS_MARKER) + if (startIdx === -1) return undefined + + const valueStart = startIdx + VITE_CSS_MARKER.length + // Vite emits `const __vite__css = ${JSON.stringify(cssContent)}` which always + // produces double-quoted JSON string literals. + if (code.charCodeAt(valueStart) !== 34) return undefined + + const codeLength = code.length + let i = valueStart + 1 + while (i < codeLength) { + const charCode = code.charCodeAt(i) + // 34 = '"' + if (charCode === 34) { + try { + return JSON.parse(code.slice(valueStart, i + 1)) + } catch { + return undefined + } + } + // 92 = '\\' + if (charCode === 92) { + i += 2 + } else { + i++ + } + } - return match[1] - .replace(/\\n/g, '\n') - .replace(/\\t/g, '\t') - .replace(/\\"/g, '"') - .replace(/\\\\/g, '\\') + return undefined }