-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(start): preserve dev SSR CSS order #7830
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import styles from '../styles/code-split-route.module.css' | ||
|
|
||
| export function CodeSplitStyledBox() { | ||
| return ( | ||
| <div className={styles.styledBox} data-testid="styled-box"> | ||
| This box should have a blue background when dev styles are enabled. | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <main> | ||
| <h1>CSS import order</h1> | ||
| <div className="css-import-order" data-testid="css-import-order"> | ||
| The route stylesheet should override its imported base stylesheet. | ||
| </div> | ||
| </main> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| .styledBox { | ||
| background-color: #3b82f6; | ||
| color: white; | ||
| padding: 24px; | ||
| border-radius: 12px; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| .css-import-order { | ||
| --css-import-base-marker: issue-7794; | ||
| background-color: #ffffff; | ||
| color: #000000; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| @import './css-import-base.css'; | ||
|
|
||
| .css-import-order { | ||
| background-color: #111827; | ||
| color: #ffffff; | ||
| padding: 24px; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<Promise<string>> = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+28
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Associate each CSS response with its navigation. The listener may collect multiple stylesheet responses before Proposed fix- const cssBodies: Array<Promise<string>> = []
- page.on('response', (response) => {
- if (
- new URL(response.url()).pathname.endsWith(
- '/@tanstack-start/styles.css',
- )
- ) {
- cssBodies.push(response.text())
- }
- })
+ const isDevStylesResponse = (response: { url(): string }) =>
+ new URL(response.url()).pathname.endsWith(
+ '/@tanstack-start/styles.css',
+ )
+ const initialResponsePromise =
+ page.waitForResponse(isDevStylesResponse)
await page.goto('/css-import-order')
await expect(page.getByTestId('css-import-order')).toBeVisible()
- await expect.poll(() => cssBodies.length).toBeGreaterThanOrEqual(1)
+ const initialCss = await (await initialResponsePromise).text()
+ const reloadedResponsePromise =
+ page.waitForResponse(isDevStylesResponse)
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))
+ const reloadedCss = await (await reloadedResponsePromise).text()📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| node_modules | ||
| dist | ||
| .routeTree.gen.ts | ||
| src/routeTree.gen.ts | ||
| test-results | ||
| playwright-report | ||
| port*.txt |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'] }, | ||
| }, | ||
| ], | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import styles from '../styles/code-split-route.module.css' | ||
|
|
||
| export function CodeSplitStyledBox() { | ||
| return ( | ||
| <div class={styles.styledBox} data-testid="styled-box"> | ||
| This box is styled by a code-split route component. | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <html> | ||
| <head> | ||
| <HydrationScript /> | ||
| <HeadContent /> | ||
| </head> | ||
| <body> | ||
| <Outlet /> | ||
| <Scripts /> | ||
| </body> | ||
| </html> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { createFileRoute } from '@tanstack/solid-router' | ||
| import { CodeSplitStyledBox } from '../components/CodeSplitStyledBox' | ||
|
|
||
| export const Route = createFileRoute('/')({ | ||
| component: Home, | ||
| }) | ||
|
|
||
| function Home() { | ||
| return ( | ||
| <main> | ||
| <h1 data-testid="home-heading">Solid Dev SSR Styles Test</h1> | ||
| <CodeSplitStyledBox /> | ||
| </main> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| body { | ||
| font-family: sans-serif; | ||
| margin: 0; | ||
| padding: 20px; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,6 @@ | ||||||||||||
| .styledBox { | ||||||||||||
| --solid-code-split-style-marker: present; | ||||||||||||
| background-color: #3b82f6; | ||||||||||||
|
Comment on lines
+2
to
+3
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Fix the Stylelint violation. Insert an empty line between the custom property and Suggested fix .styledBox {
--solid-code-split-style-marker: present;
+
background-color: `#3b82f6`;📝 Committable suggestion
Suggested change
🧰 Tools🪛 Stylelint (17.14.0)[error] 3-3: Expected empty line before declaration (declaration-empty-line-before) (declaration-empty-line-before) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||||||||||||
| color: white; | ||||||||||||
| padding: 24px; | ||||||||||||
| } | ||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Fix the Stylelint error before merging.
Stylelint reports
declaration-empty-line-beforeon Line 3. Add an empty line between the custom property andbackground-color.Proposed fix
.css-import-order { --css-import-base-marker: issue-7794; + background-color: `#ffffff`;📝 Committable suggestion
🧰 Tools
🪛 Stylelint (17.14.0)
[error] 3-3: Expected empty line before declaration (declaration-empty-line-before)
(declaration-empty-line-before)
🤖 Prompt for AI Agents
Source: Linters/SAST tools