diff --git a/.changeset/tough-bears-tease.md b/.changeset/tough-bears-tease.md new file mode 100644 index 00000000000..67fd6a3be8f --- /dev/null +++ b/.changeset/tough-bears-tease.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-router': patch +--- + +Preserve shared route stylesheet links across client navigation by rendering route CSS assets with React stylesheet precedence. diff --git a/e2e/react-start/css-modules/src/routeTree.gen.ts b/e2e/react-start/css-modules/src/routeTree.gen.ts index ab5f0b65541..d19ae80e5a0 100644 --- a/e2e/react-start/css-modules/src/routeTree.gen.ts +++ b/e2e/react-start/css-modules/src/routeTree.gen.ts @@ -12,8 +12,6 @@ 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 LazyCssStaticRouteImport } from './routes/lazy-css-static' -import { Route as LazyCssLazyRouteImport } from './routes/lazy-css-lazy' import { Route as IndexRouteImport } from './routes/index' const SassMixinRoute = SassMixinRouteImport.update({ @@ -31,16 +29,6 @@ const ModulesRoute = ModulesRouteImport.update({ path: '/modules', getParentRoute: () => rootRouteImport, } as any) -const LazyCssStaticRoute = LazyCssStaticRouteImport.update({ - id: '/lazy-css-static', - path: '/lazy-css-static', - getParentRoute: () => rootRouteImport, -} as any) -const LazyCssLazyRoute = LazyCssLazyRouteImport.update({ - id: '/lazy-css-lazy', - path: '/lazy-css-lazy', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -49,16 +37,12 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/lazy-css-lazy': typeof LazyCssLazyRoute - '/lazy-css-static': typeof LazyCssStaticRoute '/modules': typeof ModulesRoute '/quotes': typeof QuotesRoute '/sass-mixin': typeof SassMixinRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/lazy-css-lazy': typeof LazyCssLazyRoute - '/lazy-css-static': typeof LazyCssStaticRoute '/modules': typeof ModulesRoute '/quotes': typeof QuotesRoute '/sass-mixin': typeof SassMixinRoute @@ -66,43 +50,20 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/lazy-css-lazy': typeof LazyCssLazyRoute - '/lazy-css-static': typeof LazyCssStaticRoute '/modules': typeof ModulesRoute '/quotes': typeof QuotesRoute '/sass-mixin': typeof SassMixinRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/lazy-css-lazy' - | '/lazy-css-static' - | '/modules' - | '/quotes' - | '/sass-mixin' + fullPaths: '/' | '/modules' | '/quotes' | '/sass-mixin' fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/lazy-css-lazy' - | '/lazy-css-static' - | '/modules' - | '/quotes' - | '/sass-mixin' - id: - | '__root__' - | '/' - | '/lazy-css-lazy' - | '/lazy-css-static' - | '/modules' - | '/quotes' - | '/sass-mixin' + to: '/' | '/modules' | '/quotes' | '/sass-mixin' + id: '__root__' | '/' | '/modules' | '/quotes' | '/sass-mixin' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - LazyCssLazyRoute: typeof LazyCssLazyRoute - LazyCssStaticRoute: typeof LazyCssStaticRoute ModulesRoute: typeof ModulesRoute QuotesRoute: typeof QuotesRoute SassMixinRoute: typeof SassMixinRoute @@ -131,20 +92,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ModulesRouteImport parentRoute: typeof rootRouteImport } - '/lazy-css-static': { - id: '/lazy-css-static' - path: '/lazy-css-static' - fullPath: '/lazy-css-static' - preLoaderRoute: typeof LazyCssStaticRouteImport - parentRoute: typeof rootRouteImport - } - '/lazy-css-lazy': { - id: '/lazy-css-lazy' - path: '/lazy-css-lazy' - fullPath: '/lazy-css-lazy' - preLoaderRoute: typeof LazyCssLazyRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -157,8 +104,6 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - LazyCssLazyRoute: LazyCssLazyRoute, - LazyCssStaticRoute: LazyCssStaticRoute, ModulesRoute: ModulesRoute, QuotesRoute: QuotesRoute, SassMixinRoute: SassMixinRoute, diff --git a/e2e/react-start/css-modules/src/routes/__root.tsx b/e2e/react-start/css-modules/src/routes/__root.tsx index a383beacecb..d7840bc0c17 100644 --- a/e2e/react-start/css-modules/src/routes/__root.tsx +++ b/e2e/react-start/css-modules/src/routes/__root.tsx @@ -62,20 +62,6 @@ function RootComponent() { > Quoted CSS - - Lazy CSS Static - - - Lazy CSS Lazy -
diff --git a/e2e/react-start/css-modules/src/styles/shared-widget.module.css b/e2e/react-start/css-modules/src/styles/shared-widget.module.css deleted file mode 100644 index 66f3d679d82..00000000000 --- a/e2e/react-start/css-modules/src/styles/shared-widget.module.css +++ /dev/null @@ -1,19 +0,0 @@ -.widget { - background: rgb(255, 247, 237); - border: 2px solid rgb(249, 115, 22); - border-radius: 14px; - padding: 20px; -} - -.title { - color: rgb(154, 52, 18); - font-size: 20px; - font-weight: 700; - margin-bottom: 8px; -} - -.content { - color: rgb(194, 65, 12); - font-size: 14px; - line-height: 1.5; -} diff --git a/e2e/react-start/css-modules/tests/css.prod.spec.ts b/e2e/react-start/css-modules/tests/css.prod.spec.ts deleted file mode 100644 index 66aeff5700f..00000000000 --- a/e2e/react-start/css-modules/tests/css.prod.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { expect } from '@playwright/test' -import { test } from '@tanstack/router-e2e-utils' - -test.skip(process.env.MODE === 'dev', 'Prod-only repro') - -test.describe('CSS styles in SSR (prod only)', () => { - const buildUrl = (baseURL: string, path: string) => { - return baseURL.replace(/\/$/, '') + path - } - - test('CSS modules stay applied when navigating from static to lazy route', async ({ - page, - baseURL, - }) => { - await page.goto(buildUrl(baseURL!, '/lazy-css-static')) - - const widget = page.getByTestId('shared-widget') - await expect(widget).toBeVisible() - - const backgroundColor = await widget.evaluate( - (el) => getComputedStyle(el).backgroundColor, - ) - expect(backgroundColor).toBe('rgb(255, 247, 237)') - - const borderTopColor = await widget.evaluate( - (el) => getComputedStyle(el).borderTopColor, - ) - expect(borderTopColor).toBe('rgb(249, 115, 22)') - - await expect(page.getByTestId('lazy-css-static-hydrated')).toBeVisible() - - await page.getByTestId('nav-lazy-css-lazy').click() - await page.waitForURL('**/lazy-css-lazy') - await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() - - const lazyWidget = page.getByTestId('shared-widget') - await expect(lazyWidget).toBeVisible() - - await expect - .poll( - () => lazyWidget.evaluate((el) => getComputedStyle(el).backgroundColor), - { timeout: 5_000 }, - ) - .toBe('rgb(255, 247, 237)') - }) - - test('CSS modules stay applied when navigating from lazy to static route', async ({ - page, - baseURL, - }) => { - await page.goto(buildUrl(baseURL!, '/lazy-css-lazy')) - await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() - - // Wait for lazy widget to load - const lazyWidget = page.getByTestId('shared-widget') - await expect(lazyWidget).toBeVisible() - - await expect - .poll( - () => lazyWidget.evaluate((el) => getComputedStyle(el).backgroundColor), - { timeout: 5_000 }, - ) - .toBe('rgb(255, 247, 237)') - - // Navigate to static route - await page.getByTestId('nav-lazy-css-static').click() - await page.waitForURL('**/lazy-css-static') - - const staticWidget = page.getByTestId('shared-widget') - await expect(staticWidget).toBeVisible() - - await expect - .poll( - () => - staticWidget.evaluate((el) => getComputedStyle(el).backgroundColor), - { timeout: 5_000 }, - ) - .toBe('rgb(255, 247, 237)') - - const borderTopColor = await staticWidget.evaluate( - (el) => getComputedStyle(el).borderTopColor, - ) - expect(borderTopColor).toBe('rgb(249, 115, 22)') - }) - - test('CSS modules applied on direct navigation to lazy route', async ({ - page, - baseURL, - }) => { - // Navigate directly to the lazy route (cold start, no prior static route) - await page.goto(buildUrl(baseURL!, '/lazy-css-lazy')) - await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() - - const widget = page.getByTestId('shared-widget') - await expect(widget).toBeVisible() - - await expect - .poll( - () => widget.evaluate((el) => getComputedStyle(el).backgroundColor), - { timeout: 5_000 }, - ) - .toBe('rgb(255, 247, 237)') - - const borderTopColor = await widget.evaluate( - (el) => getComputedStyle(el).borderTopColor, - ) - expect(borderTopColor).toBe('rgb(249, 115, 22)') - }) - - test('CSS persists after navigating away from lazy and back', async ({ - page, - baseURL, - }) => { - await page.goto(buildUrl(baseURL!, '/lazy-css-static')) - await expect(page.getByTestId('lazy-css-static-hydrated')).toBeVisible() - - // Navigate to lazy - await page.getByTestId('nav-lazy-css-lazy').click() - await page.waitForURL('**/lazy-css-lazy') - await expect(page.getByTestId('shared-widget')).toBeVisible() - - // Navigate away to home - await page.getByTestId('nav-home').click() - await page.waitForURL(/\/([^/]*)(\/)?($|\?)/) - - // Navigate back to lazy - await page.getByTestId('nav-lazy-css-lazy').click() - await page.waitForURL('**/lazy-css-lazy') - - const widget = page.getByTestId('shared-widget') - await expect(widget).toBeVisible() - - await expect - .poll( - () => widget.evaluate((el) => getComputedStyle(el).backgroundColor), - { timeout: 5_000 }, - ) - .toBe('rgb(255, 247, 237)') - }) -}) diff --git a/e2e/react-start/rsc/tests/rsc-css-modules.spec.ts b/e2e/react-start/rsc/tests/rsc-css-modules.spec.ts index 50d461803b5..01a499a70fd 100644 --- a/e2e/react-start/rsc/tests/rsc-css-modules.spec.ts +++ b/e2e/react-start/rsc/tests/rsc-css-modules.spec.ts @@ -122,4 +122,33 @@ test.describe('RSC CSS Modules Tests', () => { ) expect(backgroundColor).toBe('rgb(224, 242, 254)') }) + + test('RSC CSS module styles persist across sibling client-side navigation', async ({ + page, + }) => { + await page.goto('/rsc-css-modules') + await page.waitForURL('/rsc-css-modules') + + const cssModulesContent = page.getByTestId('rsc-css-modules-content') + await expect(cssModulesContent).toBeVisible() + + const initialBackgroundColor = await cssModulesContent.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(initialBackgroundColor).toBe('rgb(224, 242, 254)') + + await page.getByTestId('nav-global-css').click() + await page.waitForURL('/rsc-global-css') + await expect(page.getByTestId('rsc-global-css-content')).toBeVisible() + + await page.getByTestId('nav-css-modules').click() + await page.waitForURL('/rsc-css-modules') + + await expect(cssModulesContent).toBeVisible() + + const finalBackgroundColor = await cssModulesContent.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(finalBackgroundColor).toBe('rgb(224, 242, 254)') + }) }) diff --git a/e2e/react-start/start-manifest/src/components/AppShell.tsx b/e2e/react-start/start-manifest/src/components/AppShell.tsx index 99df40387bc..f504a4fd660 100644 --- a/e2e/react-start/start-manifest/src/components/AppShell.tsx +++ b/e2e/react-start/start-manifest/src/components/AppShell.tsx @@ -3,6 +3,10 @@ import styles from '~/styles/root-shell.module.css' const ROUTES = linkOptions([ { to: '/', label: 'home' }, + { to: '/a', label: '/a' }, + { to: '/b', label: '/b' }, + { to: '/lazy-css-static', label: '/lazy-css-static' }, + { to: '/lazy-css-lazy', label: '/lazy-css-lazy' }, { to: '/r1', label: '/r1' }, { to: '/r2', label: '/r2' }, { to: '/shared-a', label: '/shared-a' }, diff --git a/e2e/react-start/start-manifest/src/components/SharedCard.tsx b/e2e/react-start/start-manifest/src/components/SharedCard.tsx new file mode 100644 index 00000000000..812e02da3d8 --- /dev/null +++ b/e2e/react-start/start-manifest/src/components/SharedCard.tsx @@ -0,0 +1,9 @@ +import styles from '~/styles/shared-card.module.css' + +export function SharedCard({ label }: { label: string }) { + return ( +
+ Shared card {label} +
+ ) +} diff --git a/e2e/react-start/css-modules/src/components/shared-widget.tsx b/e2e/react-start/start-manifest/src/components/SharedWidget.tsx similarity index 76% rename from e2e/react-start/css-modules/src/components/shared-widget.tsx rename to e2e/react-start/start-manifest/src/components/SharedWidget.tsx index 51a3be072c7..0937bd1c7b6 100644 --- a/e2e/react-start/css-modules/src/components/shared-widget.tsx +++ b/e2e/react-start/start-manifest/src/components/SharedWidget.tsx @@ -1,4 +1,3 @@ -/// import styles from '~/styles/shared-widget.module.css' export function SharedWidget() { @@ -8,7 +7,7 @@ export function SharedWidget() { Shared widget styles
- This widget uses a CSS module shared by a static route and a lazy route. + This widget uses CSS shared by a static route and a lazy route.
) diff --git a/e2e/react-start/css-modules/src/components/shared-widget-lazy.tsx b/e2e/react-start/start-manifest/src/components/SharedWidgetLazy.tsx similarity index 61% rename from e2e/react-start/css-modules/src/components/shared-widget-lazy.tsx rename to e2e/react-start/start-manifest/src/components/SharedWidgetLazy.tsx index 6d9ddce6f5f..5f1f6410a51 100644 --- a/e2e/react-start/css-modules/src/components/shared-widget-lazy.tsx +++ b/e2e/react-start/start-manifest/src/components/SharedWidgetLazy.tsx @@ -1,4 +1,4 @@ -import { SharedWidget } from './shared-widget' +import { SharedWidget } from './SharedWidget' export default function SharedWidgetLazy() { return diff --git a/e2e/react-start/start-manifest/src/routeTree.gen.ts b/e2e/react-start/start-manifest/src/routeTree.gen.ts index 0571da35921..b7ee0338424 100644 --- a/e2e/react-start/start-manifest/src/routeTree.gen.ts +++ b/e2e/react-start/start-manifest/src/routeTree.gen.ts @@ -18,6 +18,10 @@ import { Route as R4RouteImport } from './routes/r4' import { Route as R3RouteImport } from './routes/r3' import { Route as R2RouteImport } from './routes/r2' import { Route as R1RouteImport } from './routes/r1' +import { Route as LazyCssStaticRouteImport } from './routes/lazy-css-static' +import { Route as LazyCssLazyRouteImport } from './routes/lazy-css-lazy' +import { Route as BRouteImport } from './routes/b' +import { Route as ARouteImport } from './routes/a' import { Route as IndexRouteImport } from './routes/index' const SharedCRoute = SharedCRouteImport.update({ @@ -65,6 +69,26 @@ const R1Route = R1RouteImport.update({ path: '/r1', getParentRoute: () => rootRouteImport, } as any) +const LazyCssStaticRoute = LazyCssStaticRouteImport.update({ + id: '/lazy-css-static', + path: '/lazy-css-static', + getParentRoute: () => rootRouteImport, +} as any) +const LazyCssLazyRoute = LazyCssLazyRouteImport.update({ + id: '/lazy-css-lazy', + path: '/lazy-css-lazy', + getParentRoute: () => rootRouteImport, +} as any) +const BRoute = BRouteImport.update({ + id: '/b', + path: '/b', + getParentRoute: () => rootRouteImport, +} as any) +const ARoute = ARouteImport.update({ + id: '/a', + path: '/a', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -73,6 +97,10 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/a': typeof ARoute + '/b': typeof BRoute + '/lazy-css-lazy': typeof LazyCssLazyRoute + '/lazy-css-static': typeof LazyCssStaticRoute '/r1': typeof R1Route '/r2': typeof R2Route '/r3': typeof R3Route @@ -85,6 +113,10 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/a': typeof ARoute + '/b': typeof BRoute + '/lazy-css-lazy': typeof LazyCssLazyRoute + '/lazy-css-static': typeof LazyCssStaticRoute '/r1': typeof R1Route '/r2': typeof R2Route '/r3': typeof R3Route @@ -98,6 +130,10 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/a': typeof ARoute + '/b': typeof BRoute + '/lazy-css-lazy': typeof LazyCssLazyRoute + '/lazy-css-static': typeof LazyCssStaticRoute '/r1': typeof R1Route '/r2': typeof R2Route '/r3': typeof R3Route @@ -112,6 +148,10 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/a' + | '/b' + | '/lazy-css-lazy' + | '/lazy-css-static' | '/r1' | '/r2' | '/r3' @@ -124,6 +164,10 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/a' + | '/b' + | '/lazy-css-lazy' + | '/lazy-css-static' | '/r1' | '/r2' | '/r3' @@ -136,6 +180,10 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/a' + | '/b' + | '/lazy-css-lazy' + | '/lazy-css-static' | '/r1' | '/r2' | '/r3' @@ -149,6 +197,10 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ARoute: typeof ARoute + BRoute: typeof BRoute + LazyCssLazyRoute: typeof LazyCssLazyRoute + LazyCssStaticRoute: typeof LazyCssStaticRoute R1Route: typeof R1Route R2Route: typeof R2Route R3Route: typeof R3Route @@ -225,6 +277,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof R1RouteImport parentRoute: typeof rootRouteImport } + '/lazy-css-static': { + id: '/lazy-css-static' + path: '/lazy-css-static' + fullPath: '/lazy-css-static' + preLoaderRoute: typeof LazyCssStaticRouteImport + parentRoute: typeof rootRouteImport + } + '/lazy-css-lazy': { + id: '/lazy-css-lazy' + path: '/lazy-css-lazy' + fullPath: '/lazy-css-lazy' + preLoaderRoute: typeof LazyCssLazyRouteImport + parentRoute: typeof rootRouteImport + } + '/b': { + id: '/b' + path: '/b' + fullPath: '/b' + preLoaderRoute: typeof BRouteImport + parentRoute: typeof rootRouteImport + } + '/a': { + id: '/a' + path: '/a' + fullPath: '/a' + preLoaderRoute: typeof ARouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -237,6 +317,10 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ARoute: ARoute, + BRoute: BRoute, + LazyCssLazyRoute: LazyCssLazyRoute, + LazyCssStaticRoute: LazyCssStaticRoute, R1Route: R1Route, R2Route: R2Route, R3Route: R3Route, diff --git a/e2e/react-start/start-manifest/src/routes/a.tsx b/e2e/react-start/start-manifest/src/routes/a.tsx new file mode 100644 index 00000000000..434803920dc --- /dev/null +++ b/e2e/react-start/start-manifest/src/routes/a.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SharedCard } from '~/components/SharedCard' +import styles from '~/styles/page-a.module.css' + +export const Route = createFileRoute('/a')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Route /a

+

Route /a keeps the shared card styled.

+ +
+ ) +} diff --git a/e2e/react-start/start-manifest/src/routes/b.tsx b/e2e/react-start/start-manifest/src/routes/b.tsx new file mode 100644 index 00000000000..a418aeabeb0 --- /dev/null +++ b/e2e/react-start/start-manifest/src/routes/b.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SharedCard } from '~/components/SharedCard' +import styles from '~/styles/page-b.module.css' + +export const Route = createFileRoute('/b')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Route /b

+

Route /b should keep the shared card stylesheet after nav.

+ +
+ ) +} diff --git a/e2e/react-start/css-modules/src/routes/lazy-css-lazy.tsx b/e2e/react-start/start-manifest/src/routes/lazy-css-lazy.tsx similarity index 89% rename from e2e/react-start/css-modules/src/routes/lazy-css-lazy.tsx rename to e2e/react-start/start-manifest/src/routes/lazy-css-lazy.tsx index 4d147a96519..28cec3aff35 100644 --- a/e2e/react-start/css-modules/src/routes/lazy-css-lazy.tsx +++ b/e2e/react-start/start-manifest/src/routes/lazy-css-lazy.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { createFileRoute } from '@tanstack/react-router' const LazySharedWidget = React.lazy( - () => import('~/components/shared-widget-lazy'), + () => import('~/components/SharedWidgetLazy'), ) export const Route = createFileRoute('/lazy-css-lazy')({ @@ -11,7 +11,7 @@ export const Route = createFileRoute('/lazy-css-lazy')({ function LazyCssLazyRoute() { return ( -
+

Lazy CSS Repro - Lazy Route

This route renders the same widget through React.lazy so the CSS only @@ -23,6 +23,6 @@ function LazyCssLazyRoute() { > -

+ ) } diff --git a/e2e/react-start/css-modules/src/routes/lazy-css-static.tsx b/e2e/react-start/start-manifest/src/routes/lazy-css-static.tsx similarity index 85% rename from e2e/react-start/css-modules/src/routes/lazy-css-static.tsx rename to e2e/react-start/start-manifest/src/routes/lazy-css-static.tsx index c9e3f33a0eb..349e90f33e3 100644 --- a/e2e/react-start/css-modules/src/routes/lazy-css-static.tsx +++ b/e2e/react-start/start-manifest/src/routes/lazy-css-static.tsx @@ -1,5 +1,5 @@ import { ClientOnly, createFileRoute } from '@tanstack/react-router' -import { SharedWidget } from '~/components/shared-widget' +import { SharedWidget } from '~/components/SharedWidget' export const Route = createFileRoute('/lazy-css-static')({ component: LazyCssStaticRoute, @@ -7,7 +7,7 @@ export const Route = createFileRoute('/lazy-css-static')({ function LazyCssStaticRoute() { return ( -
+

Lazy CSS Repro - Static Route

This route statically imports the shared widget so its CSS is present in @@ -19,6 +19,6 @@ function LazyCssStaticRoute() { -

+ ) } diff --git a/e2e/react-start/start-manifest/src/styles/page-a.module.css b/e2e/react-start/start-manifest/src/styles/page-a.module.css new file mode 100644 index 00000000000..7ed4301097f --- /dev/null +++ b/e2e/react-start/start-manifest/src/styles/page-a.module.css @@ -0,0 +1,10 @@ +.page { + padding: 1rem; + min-height: 12rem; + background: rgb(240, 253, 244); + border: 4px solid rgb(22, 163, 74); +} + +.title { + color: rgb(21, 128, 61); +} diff --git a/e2e/react-start/start-manifest/src/styles/page-b.module.css b/e2e/react-start/start-manifest/src/styles/page-b.module.css new file mode 100644 index 00000000000..93d1d39a014 --- /dev/null +++ b/e2e/react-start/start-manifest/src/styles/page-b.module.css @@ -0,0 +1,10 @@ +.page { + padding: 1rem; + min-height: 12rem; + background: rgb(239, 246, 255); + border: 4px solid rgb(37, 99, 235); +} + +.title { + color: rgb(29, 78, 216); +} diff --git a/e2e/react-start/start-manifest/src/styles/shared-card.module.css b/e2e/react-start/start-manifest/src/styles/shared-card.module.css new file mode 100644 index 00000000000..2aa5a6ac17f --- /dev/null +++ b/e2e/react-start/start-manifest/src/styles/shared-card.module.css @@ -0,0 +1,8 @@ +.card { + margin-top: 1rem; + padding: 1.5rem; + border-radius: 0.75rem; + background: rgb(252, 231, 243); + border: 2px solid rgb(190, 24, 93); + color: rgb(157, 23, 77); +} diff --git a/e2e/react-start/start-manifest/src/styles/shared-widget.module.css b/e2e/react-start/start-manifest/src/styles/shared-widget.module.css new file mode 100644 index 00000000000..ed197cddb07 --- /dev/null +++ b/e2e/react-start/start-manifest/src/styles/shared-widget.module.css @@ -0,0 +1,17 @@ +.widget { + margin-top: 1rem; + padding: 1rem; + border-top: 4px solid rgb(249, 115, 22); + border-radius: 0.75rem; + background: rgb(255, 247, 237); +} + +.title { + font-weight: 700; + color: rgb(154, 52, 18); +} + +.content { + margin-top: 0.5rem; + color: rgb(124, 45, 18); +} diff --git a/e2e/react-start/start-manifest/tests/start-manifest.spec.ts b/e2e/react-start/start-manifest/tests/start-manifest.spec.ts index d04d42b2dbd..1767728ac3d 100644 --- a/e2e/react-start/start-manifest/tests/start-manifest.spec.ts +++ b/e2e/react-start/start-manifest/tests/start-manifest.spec.ts @@ -5,11 +5,12 @@ import { readdir } from 'node:fs/promises' import path from 'node:path' import { pathToFileURL } from 'node:url' -test.skip(process.env.MODE === 'dev', 'Prod-only manifest dehydration coverage') - const ROOT_SHELL_COLOR = 'rgb(22, 101, 52)' const ROUTE_ONE_COLOR = 'rgb(30, 64, 175)' const ROUTE_TWO_COLOR = 'rgb(126, 34, 206)' +const SHARED_CARD_BG = 'rgb(252, 231, 243)' +const SHARED_WIDGET_BG = 'rgb(255, 247, 237)' +const SHARED_WIDGET_BORDER = 'rgb(249, 115, 22)' const buildUrl = (baseURL: string, pathname: string) => baseURL.replace(/\/$/, '') + pathname @@ -20,6 +21,12 @@ async function getColor(testId: string, page: Page) { .evaluate((element) => getComputedStyle(element).color) } +async function getBackgroundColor(testId: string, page: Page) { + return page + .getByTestId(testId) + .evaluate((element) => getComputedStyle(element).backgroundColor) +} + function getStylesheetHrefsFromHtml(html: string) { return Array.from( html.matchAll(/]+rel="stylesheet"[^>]+href="([^"]+)"/g), @@ -313,3 +320,134 @@ test('built start manifest preserves shared layout asset identity across sibling expect(sharedAAsset).toBe(sharedBAsset) expect(sharedBAsset).toBe(sharedCAsset) }) + +test('shared CSS chunk persists across client-side nav', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/a')) + + await expect(page.getByTestId('page-a')).toBeVisible() + await expect(page.getByTestId('shared-card')).toBeVisible() + await expect + .poll(() => getBackgroundColor('shared-card', page)) + .toBe(SHARED_CARD_BG) + await expect( + page.locator('head link[rel="stylesheet"][href*="SharedCard"]'), + ).toHaveCount(1) + + await page.getByTestId('nav-/b').click() + await page.waitForURL('**/b') + await expect(page.getByTestId('page-b')).toBeVisible() + + await expect( + page.locator('head link[rel="stylesheet"][href*="SharedCard"]'), + ).toHaveCount(1) + await expect + .poll(() => getBackgroundColor('shared-card', page)) + .toBe(SHARED_CARD_BG) +}) + +test('shared widget CSS stays applied when navigating from static to lazy route', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-static')) + + const widget = page.getByTestId('shared-widget') + await expect(widget).toBeVisible() + expect(await getBackgroundColor('shared-widget', page)).toBe(SHARED_WIDGET_BG) + expect( + await widget.evaluate( + (element) => getComputedStyle(element).borderTopColor, + ), + ).toBe(SHARED_WIDGET_BORDER) + + await expect(page.getByTestId('lazy-css-static-hydrated')).toBeVisible() + + await page.getByTestId('nav-/lazy-css-lazy').click() + await page.waitForURL('**/lazy-css-lazy') + await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) +}) + +test('shared widget CSS stays applied when navigating from lazy to static route', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-lazy')) + await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) + + await page.getByTestId('nav-/lazy-css-static').click() + await page.waitForURL('**/lazy-css-static') + + const widget = page.getByTestId('shared-widget') + await expect(widget).toBeVisible() + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) + expect( + await widget.evaluate( + (element) => getComputedStyle(element).borderTopColor, + ), + ).toBe(SHARED_WIDGET_BORDER) +}) + +test('shared widget CSS is applied on direct navigation to lazy route', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-lazy')) + await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() + + const widget = page.getByTestId('shared-widget') + await expect(widget).toBeVisible() + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) + expect( + await widget.evaluate( + (element) => getComputedStyle(element).borderTopColor, + ), + ).toBe(SHARED_WIDGET_BORDER) +}) + +test('shared widget CSS persists after navigating away from lazy and back', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-static')) + await expect(page.getByTestId('lazy-css-static-hydrated')).toBeVisible() + + await page.getByTestId('nav-/lazy-css-lazy').click() + await page.waitForURL('**/lazy-css-lazy') + await expect(page.getByTestId('shared-widget')).toBeVisible() + + await page.getByTestId('nav-home').click() + await page.waitForURL((url) => url.pathname === '/') + + await page.getByTestId('nav-/lazy-css-lazy').click() + await page.waitForURL('**/lazy-css-lazy') + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) +}) diff --git a/e2e/solid-start/start-manifest/package.json b/e2e/solid-start/start-manifest/package.json new file mode 100644 index 00000000000..1fe9c87c943 --- /dev/null +++ b/e2e/solid-start/start-manifest/package.json @@ -0,0 +1,33 @@ +{ + "name": "tanstack-solid-start-e2e-start-manifest", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/solid-router": "workspace:^", + "@tanstack/solid-start": "workspace:^", + "solid-js": "^1.9.10" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "srvx": "^0.11.9", + "typescript": "^6.0.2", + "vite": "^8.0.0", + "vite-plugin-solid": "^2.11.11" + }, + "nx": { + "targets": { + "test:e2e": { + "parallelism": false + } + } + } +} diff --git a/e2e/solid-start/start-manifest/playwright.config.ts b/e2e/solid-start/start-manifest/playwright.config.ts new file mode 100644 index 00000000000..b9f835c16a6 --- /dev/null +++ b/e2e/solid-start/start-manifest/playwright.config.ts @@ -0,0 +1,30 @@ +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']], + + use: { + baseURL, + }, + + webServer: { + command: `pnpm build && PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/solid-start/start-manifest/src/components/AppShell.tsx b/e2e/solid-start/start-manifest/src/components/AppShell.tsx new file mode 100644 index 00000000000..cb1101c0c42 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/components/AppShell.tsx @@ -0,0 +1,38 @@ +/// +import { ClientOnly, Link, Outlet, linkOptions } from '@tanstack/solid-router' +import styles from '~/styles/root-shell.module.css' + +const ROUTES = linkOptions([ + { to: '/', label: 'home' }, + { to: '/a', label: '/a' }, + { to: '/b', label: '/b' }, + { to: '/lazy-css-static', label: '/lazy-css-static' }, + { to: '/lazy-css-lazy', label: '/lazy-css-lazy' }, + { to: '/r1', label: '/r1' }, + { to: '/r2', label: '/r2' }, + { to: '/shared-a', label: '/shared-a' }, +]) + +export function AppShell() { + return ( +
+ + +
+
+ Start manifest CSS root shell +
+ +
hydrated
+
+ +
+
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/components/SharedCard.tsx b/e2e/solid-start/start-manifest/src/components/SharedCard.tsx new file mode 100644 index 00000000000..c82b2b0ea55 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/components/SharedCard.tsx @@ -0,0 +1,10 @@ +/// +import styles from '~/styles/shared-card.module.css' + +export function SharedCard({ label }: { label: string }) { + return ( +
+ Shared card {label} +
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/components/SharedNestedLayout.tsx b/e2e/solid-start/start-manifest/src/components/SharedNestedLayout.tsx new file mode 100644 index 00000000000..5e86c0e8ad4 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/components/SharedNestedLayout.tsx @@ -0,0 +1,31 @@ +/// +import { Link, linkOptions } from '@tanstack/solid-router' +import styles from '~/styles/shared-layout.module.css' + +const ROUTES = linkOptions([ + { to: '/shared-a', label: '/shared-a' }, + { to: '/shared-b', label: '/shared-b' }, + { to: '/shared-c', label: '/shared-c' }, +]) + +export function SharedNestedLayout(props: { children?: any }) { + return ( +
+
+ Shared nested layout CSS +
+ + + +
+ {props.children} +
+
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/components/SharedWidget.tsx b/e2e/solid-start/start-manifest/src/components/SharedWidget.tsx new file mode 100644 index 00000000000..9074d5b2b0c --- /dev/null +++ b/e2e/solid-start/start-manifest/src/components/SharedWidget.tsx @@ -0,0 +1,15 @@ +/// +import styles from '~/styles/shared-widget.module.css' + +export function SharedWidget() { + return ( +
+
+ Shared widget styles +
+
+ This widget uses CSS shared by a static route and a lazy route. +
+
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/components/SharedWidgetLazy.tsx b/e2e/solid-start/start-manifest/src/components/SharedWidgetLazy.tsx new file mode 100644 index 00000000000..5f1f6410a51 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/components/SharedWidgetLazy.tsx @@ -0,0 +1,5 @@ +import { SharedWidget } from './SharedWidget' + +export default function SharedWidgetLazy() { + return +} diff --git a/e2e/solid-start/start-manifest/src/routeTree.gen.ts b/e2e/solid-start/start-manifest/src/routeTree.gen.ts new file mode 100644 index 00000000000..982f80b1502 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routeTree.gen.ts @@ -0,0 +1,345 @@ +/* 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 SharedCRouteImport } from './routes/shared-c' +import { Route as SharedBRouteImport } from './routes/shared-b' +import { Route as SharedARouteImport } from './routes/shared-a' +import { Route as R6RouteImport } from './routes/r6' +import { Route as R5RouteImport } from './routes/r5' +import { Route as R4RouteImport } from './routes/r4' +import { Route as R3RouteImport } from './routes/r3' +import { Route as R2RouteImport } from './routes/r2' +import { Route as R1RouteImport } from './routes/r1' +import { Route as LazyCssStaticRouteImport } from './routes/lazy-css-static' +import { Route as LazyCssLazyRouteImport } from './routes/lazy-css-lazy' +import { Route as BRouteImport } from './routes/b' +import { Route as ARouteImport } from './routes/a' +import { Route as IndexRouteImport } from './routes/index' + +const SharedCRoute = SharedCRouteImport.update({ + id: '/shared-c', + path: '/shared-c', + getParentRoute: () => rootRouteImport, +} as any) +const SharedBRoute = SharedBRouteImport.update({ + id: '/shared-b', + path: '/shared-b', + getParentRoute: () => rootRouteImport, +} as any) +const SharedARoute = SharedARouteImport.update({ + id: '/shared-a', + path: '/shared-a', + getParentRoute: () => rootRouteImport, +} as any) +const R6Route = R6RouteImport.update({ + id: '/r6', + path: '/r6', + getParentRoute: () => rootRouteImport, +} as any) +const R5Route = R5RouteImport.update({ + id: '/r5', + path: '/r5', + getParentRoute: () => rootRouteImport, +} as any) +const R4Route = R4RouteImport.update({ + id: '/r4', + path: '/r4', + getParentRoute: () => rootRouteImport, +} as any) +const R3Route = R3RouteImport.update({ + id: '/r3', + path: '/r3', + getParentRoute: () => rootRouteImport, +} as any) +const R2Route = R2RouteImport.update({ + id: '/r2', + path: '/r2', + getParentRoute: () => rootRouteImport, +} as any) +const R1Route = R1RouteImport.update({ + id: '/r1', + path: '/r1', + getParentRoute: () => rootRouteImport, +} as any) +const LazyCssStaticRoute = LazyCssStaticRouteImport.update({ + id: '/lazy-css-static', + path: '/lazy-css-static', + getParentRoute: () => rootRouteImport, +} as any) +const LazyCssLazyRoute = LazyCssLazyRouteImport.update({ + id: '/lazy-css-lazy', + path: '/lazy-css-lazy', + getParentRoute: () => rootRouteImport, +} as any) +const BRoute = BRouteImport.update({ + id: '/b', + path: '/b', + getParentRoute: () => rootRouteImport, +} as any) +const ARoute = ARouteImport.update({ + id: '/a', + path: '/a', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/a': typeof ARoute + '/b': typeof BRoute + '/lazy-css-lazy': typeof LazyCssLazyRoute + '/lazy-css-static': typeof LazyCssStaticRoute + '/r1': typeof R1Route + '/r2': typeof R2Route + '/r3': typeof R3Route + '/r4': typeof R4Route + '/r5': typeof R5Route + '/r6': typeof R6Route + '/shared-a': typeof SharedARoute + '/shared-b': typeof SharedBRoute + '/shared-c': typeof SharedCRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/a': typeof ARoute + '/b': typeof BRoute + '/lazy-css-lazy': typeof LazyCssLazyRoute + '/lazy-css-static': typeof LazyCssStaticRoute + '/r1': typeof R1Route + '/r2': typeof R2Route + '/r3': typeof R3Route + '/r4': typeof R4Route + '/r5': typeof R5Route + '/r6': typeof R6Route + '/shared-a': typeof SharedARoute + '/shared-b': typeof SharedBRoute + '/shared-c': typeof SharedCRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/a': typeof ARoute + '/b': typeof BRoute + '/lazy-css-lazy': typeof LazyCssLazyRoute + '/lazy-css-static': typeof LazyCssStaticRoute + '/r1': typeof R1Route + '/r2': typeof R2Route + '/r3': typeof R3Route + '/r4': typeof R4Route + '/r5': typeof R5Route + '/r6': typeof R6Route + '/shared-a': typeof SharedARoute + '/shared-b': typeof SharedBRoute + '/shared-c': typeof SharedCRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/a' + | '/b' + | '/lazy-css-lazy' + | '/lazy-css-static' + | '/r1' + | '/r2' + | '/r3' + | '/r4' + | '/r5' + | '/r6' + | '/shared-a' + | '/shared-b' + | '/shared-c' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/a' + | '/b' + | '/lazy-css-lazy' + | '/lazy-css-static' + | '/r1' + | '/r2' + | '/r3' + | '/r4' + | '/r5' + | '/r6' + | '/shared-a' + | '/shared-b' + | '/shared-c' + id: + | '__root__' + | '/' + | '/a' + | '/b' + | '/lazy-css-lazy' + | '/lazy-css-static' + | '/r1' + | '/r2' + | '/r3' + | '/r4' + | '/r5' + | '/r6' + | '/shared-a' + | '/shared-b' + | '/shared-c' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ARoute: typeof ARoute + BRoute: typeof BRoute + LazyCssLazyRoute: typeof LazyCssLazyRoute + LazyCssStaticRoute: typeof LazyCssStaticRoute + R1Route: typeof R1Route + R2Route: typeof R2Route + R3Route: typeof R3Route + R4Route: typeof R4Route + R5Route: typeof R5Route + R6Route: typeof R6Route + SharedARoute: typeof SharedARoute + SharedBRoute: typeof SharedBRoute + SharedCRoute: typeof SharedCRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/shared-c': { + id: '/shared-c' + path: '/shared-c' + fullPath: '/shared-c' + preLoaderRoute: typeof SharedCRouteImport + parentRoute: typeof rootRouteImport + } + '/shared-b': { + id: '/shared-b' + path: '/shared-b' + fullPath: '/shared-b' + preLoaderRoute: typeof SharedBRouteImport + parentRoute: typeof rootRouteImport + } + '/shared-a': { + id: '/shared-a' + path: '/shared-a' + fullPath: '/shared-a' + preLoaderRoute: typeof SharedARouteImport + parentRoute: typeof rootRouteImport + } + '/r6': { + id: '/r6' + path: '/r6' + fullPath: '/r6' + preLoaderRoute: typeof R6RouteImport + parentRoute: typeof rootRouteImport + } + '/r5': { + id: '/r5' + path: '/r5' + fullPath: '/r5' + preLoaderRoute: typeof R5RouteImport + parentRoute: typeof rootRouteImport + } + '/r4': { + id: '/r4' + path: '/r4' + fullPath: '/r4' + preLoaderRoute: typeof R4RouteImport + parentRoute: typeof rootRouteImport + } + '/r3': { + id: '/r3' + path: '/r3' + fullPath: '/r3' + preLoaderRoute: typeof R3RouteImport + parentRoute: typeof rootRouteImport + } + '/r2': { + id: '/r2' + path: '/r2' + fullPath: '/r2' + preLoaderRoute: typeof R2RouteImport + parentRoute: typeof rootRouteImport + } + '/r1': { + id: '/r1' + path: '/r1' + fullPath: '/r1' + preLoaderRoute: typeof R1RouteImport + parentRoute: typeof rootRouteImport + } + '/lazy-css-static': { + id: '/lazy-css-static' + path: '/lazy-css-static' + fullPath: '/lazy-css-static' + preLoaderRoute: typeof LazyCssStaticRouteImport + parentRoute: typeof rootRouteImport + } + '/lazy-css-lazy': { + id: '/lazy-css-lazy' + path: '/lazy-css-lazy' + fullPath: '/lazy-css-lazy' + preLoaderRoute: typeof LazyCssLazyRouteImport + parentRoute: typeof rootRouteImport + } + '/b': { + id: '/b' + path: '/b' + fullPath: '/b' + preLoaderRoute: typeof BRouteImport + parentRoute: typeof rootRouteImport + } + '/a': { + id: '/a' + path: '/a' + fullPath: '/a' + preLoaderRoute: typeof ARouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ARoute: ARoute, + BRoute: BRoute, + LazyCssLazyRoute: LazyCssLazyRoute, + LazyCssStaticRoute: LazyCssStaticRoute, + R1Route: R1Route, + R2Route: R2Route, + R3Route: R3Route, + R4Route: R4Route, + R5Route: R5Route, + R6Route: R6Route, + SharedARoute: SharedARoute, + SharedBRoute: SharedBRoute, + SharedCRoute: SharedCRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/solid-start' +declare module '@tanstack/solid-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/solid-start/start-manifest/src/router.tsx b/e2e/solid-start/start-manifest/src/router.tsx new file mode 100644 index 00000000000..801095c695d --- /dev/null +++ b/e2e/solid-start/start-manifest/src/router.tsx @@ -0,0 +1,18 @@ +import { createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' + +export const getRouter = () => { + const router = createRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: 'intent', + }) + + return router +} + +declare module '@tanstack/solid-router' { + interface Register { + router: ReturnType + } +} diff --git a/e2e/solid-start/start-manifest/src/routes/__root.tsx b/e2e/solid-start/start-manifest/src/routes/__root.tsx new file mode 100644 index 00000000000..90935e12367 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/__root.tsx @@ -0,0 +1,29 @@ +import { HeadContent, Scripts, createRootRoute } from '@tanstack/solid-router' +import { HydrationScript } from 'solid-js/web' +import { AppShell } from '~/components/AppShell' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'TanStack Start Manifest Bloat E2E' }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + + ) +} diff --git a/e2e/solid-start/start-manifest/src/routes/a.tsx b/e2e/solid-start/start-manifest/src/routes/a.tsx new file mode 100644 index 00000000000..d9cda03adcb --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/a.tsx @@ -0,0 +1,18 @@ +/// +import { createFileRoute } from '@tanstack/solid-router' +import { SharedCard } from '~/components/SharedCard' +import styles from '~/styles/page-a.module.css' + +export const Route = createFileRoute('/a')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Route /a

+

Route /a keeps the shared card styled.

+ +
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/routes/b.tsx b/e2e/solid-start/start-manifest/src/routes/b.tsx new file mode 100644 index 00000000000..fd17811d56d --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/b.tsx @@ -0,0 +1,18 @@ +/// +import { createFileRoute } from '@tanstack/solid-router' +import { SharedCard } from '~/components/SharedCard' +import styles from '~/styles/page-b.module.css' + +export const Route = createFileRoute('/b')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Route /b

+

Route /b should keep the shared card stylesheet after nav.

+ +
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/routes/index.tsx b/e2e/solid-start/start-manifest/src/routes/index.tsx new file mode 100644 index 00000000000..e61358aa7b4 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/index.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/')({ + component: HomeRoute, +}) + +function HomeRoute() { + return ( +
+

Start manifest fixture

+

+ Use this page to navigate between CSS module routes. +

+
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/routes/lazy-css-lazy.tsx b/e2e/solid-start/start-manifest/src/routes/lazy-css-lazy.tsx new file mode 100644 index 00000000000..dc2bb845bfd --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/lazy-css-lazy.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { Suspense, lazy } from 'solid-js' + +const LazySharedWidget = lazy(() => import('~/components/SharedWidgetLazy')) + +export const Route = createFileRoute('/lazy-css-lazy')({ + component: LazyCssLazyRoute, +}) + +function LazyCssLazyRoute() { + return ( +
+

Lazy CSS Repro - Lazy Route

+

+ This route renders the same widget through a lazy component so the CSS + only exists behind a dynamic import boundary. +

+ + Loading...} + > + + +
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/routes/lazy-css-static.tsx b/e2e/solid-start/start-manifest/src/routes/lazy-css-static.tsx new file mode 100644 index 00000000000..1112c8ae900 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/lazy-css-static.tsx @@ -0,0 +1,24 @@ +import { ClientOnly, createFileRoute } from '@tanstack/solid-router' +import { SharedWidget } from '~/components/SharedWidget' + +export const Route = createFileRoute('/lazy-css-static')({ + component: LazyCssStaticRoute, +}) + +function LazyCssStaticRoute() { + return ( +
+

Lazy CSS Repro - Static Route

+

+ This route statically imports the shared widget so its CSS is present in + the SSR head. +

+ + +
hydrated
+
+ + +
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/routes/r1.tsx b/e2e/solid-start/start-manifest/src/routes/r1.tsx new file mode 100644 index 00000000000..d3979ecaf11 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/r1.tsx @@ -0,0 +1,18 @@ +/// +import { createFileRoute } from '@tanstack/solid-router' +import styles from '~/styles/route-one.module.css' + +export const Route = createFileRoute('/r1')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Route /r1

+
+ Route /r1 CSS module styling +
+
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/routes/r2.tsx b/e2e/solid-start/start-manifest/src/routes/r2.tsx new file mode 100644 index 00000000000..836257cd2eb --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/r2.tsx @@ -0,0 +1,18 @@ +/// +import { createFileRoute } from '@tanstack/solid-router' +import styles from '~/styles/route-two.module.css' + +export const Route = createFileRoute('/r2')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Route /r2

+
+ Route /r2 CSS module styling +
+
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/routes/r3.tsx b/e2e/solid-start/start-manifest/src/routes/r3.tsx new file mode 100644 index 00000000000..178a4b2a41d --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/r3.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/r3')({ + component: () =>
Route /r3
, +}) diff --git a/e2e/solid-start/start-manifest/src/routes/r4.tsx b/e2e/solid-start/start-manifest/src/routes/r4.tsx new file mode 100644 index 00000000000..c625e4961ea --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/r4.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/r4')({ + component: () =>
Route /r4
, +}) diff --git a/e2e/solid-start/start-manifest/src/routes/r5.tsx b/e2e/solid-start/start-manifest/src/routes/r5.tsx new file mode 100644 index 00000000000..d4f5dcb95f2 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/r5.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/r5')({ + component: () =>
Route /r5
, +}) diff --git a/e2e/solid-start/start-manifest/src/routes/r6.tsx b/e2e/solid-start/start-manifest/src/routes/r6.tsx new file mode 100644 index 00000000000..235ff13538e --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/r6.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/r6')({ + component: () =>
Route /r6
, +}) diff --git a/e2e/solid-start/start-manifest/src/routes/shared-a.tsx b/e2e/solid-start/start-manifest/src/routes/shared-a.tsx new file mode 100644 index 00000000000..ccb94d1e469 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/shared-a.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { SharedNestedLayout } from '~/components/SharedNestedLayout' + +export const Route = createFileRoute('/shared-a')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( + +
Shared route A
+
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/routes/shared-b.tsx b/e2e/solid-start/start-manifest/src/routes/shared-b.tsx new file mode 100644 index 00000000000..ced02fcf126 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/shared-b.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { SharedNestedLayout } from '~/components/SharedNestedLayout' + +export const Route = createFileRoute('/shared-b')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( + +
Shared route B
+
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/routes/shared-c.tsx b/e2e/solid-start/start-manifest/src/routes/shared-c.tsx new file mode 100644 index 00000000000..9b8ea62f3f1 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/routes/shared-c.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { SharedNestedLayout } from '~/components/SharedNestedLayout' + +export const Route = createFileRoute('/shared-c')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( + +
Shared route C
+
+ ) +} diff --git a/e2e/solid-start/start-manifest/src/styles/page-a.module.css b/e2e/solid-start/start-manifest/src/styles/page-a.module.css new file mode 100644 index 00000000000..7ed4301097f --- /dev/null +++ b/e2e/solid-start/start-manifest/src/styles/page-a.module.css @@ -0,0 +1,10 @@ +.page { + padding: 1rem; + min-height: 12rem; + background: rgb(240, 253, 244); + border: 4px solid rgb(22, 163, 74); +} + +.title { + color: rgb(21, 128, 61); +} diff --git a/e2e/solid-start/start-manifest/src/styles/page-b.module.css b/e2e/solid-start/start-manifest/src/styles/page-b.module.css new file mode 100644 index 00000000000..93d1d39a014 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/styles/page-b.module.css @@ -0,0 +1,10 @@ +.page { + padding: 1rem; + min-height: 12rem; + background: rgb(239, 246, 255); + border: 4px solid rgb(37, 99, 235); +} + +.title { + color: rgb(29, 78, 216); +} diff --git a/e2e/solid-start/start-manifest/src/styles/root-shell.module.css b/e2e/solid-start/start-manifest/src/styles/root-shell.module.css new file mode 100644 index 00000000000..e7bece86643 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/styles/root-shell.module.css @@ -0,0 +1,37 @@ +.shell { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + min-height: 100vh; + background: rgb(248, 250, 252); + color: rgb(15, 23, 42); +} + +.nav { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + padding: 1rem; + border-bottom: 1px solid rgb(226, 232, 240); + background: white; +} + +.nav a { + color: rgb(37, 99, 235); + text-decoration: none; +} + +.content { + max-width: 760px; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; +} + +.rootBadge { + display: inline-block; + margin-bottom: 1rem; + padding: 0.375rem 0.75rem; + border-radius: 9999px; + background: rgb(220, 252, 231); + color: rgb(22, 101, 52); + font-weight: 700; +} diff --git a/e2e/solid-start/start-manifest/src/styles/route-one.module.css b/e2e/solid-start/start-manifest/src/styles/route-one.module.css new file mode 100644 index 00000000000..623d03e6a34 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/styles/route-one.module.css @@ -0,0 +1,7 @@ +.card { + padding: 1rem; + border-radius: 0.75rem; + background: rgb(239, 246, 255); + color: rgb(30, 64, 175); + border: 2px solid rgb(96, 165, 250); +} diff --git a/e2e/solid-start/start-manifest/src/styles/route-two.module.css b/e2e/solid-start/start-manifest/src/styles/route-two.module.css new file mode 100644 index 00000000000..66ed3557119 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/styles/route-two.module.css @@ -0,0 +1,7 @@ +.card { + padding: 1rem; + border-radius: 0.75rem; + background: rgb(250, 245, 255); + color: rgb(126, 34, 206); + border: 2px solid rgb(192, 132, 252); +} diff --git a/e2e/solid-start/start-manifest/src/styles/shared-card.module.css b/e2e/solid-start/start-manifest/src/styles/shared-card.module.css new file mode 100644 index 00000000000..2aa5a6ac17f --- /dev/null +++ b/e2e/solid-start/start-manifest/src/styles/shared-card.module.css @@ -0,0 +1,8 @@ +.card { + margin-top: 1rem; + padding: 1.5rem; + border-radius: 0.75rem; + background: rgb(252, 231, 243); + border: 2px solid rgb(190, 24, 93); + color: rgb(157, 23, 77); +} diff --git a/e2e/solid-start/start-manifest/src/styles/shared-layout.module.css b/e2e/solid-start/start-manifest/src/styles/shared-layout.module.css new file mode 100644 index 00000000000..6025a1da066 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/styles/shared-layout.module.css @@ -0,0 +1,16 @@ +.layout { + padding: 1rem; + border-radius: 0.75rem; + background: rgb(255, 251, 235); + border: 2px solid rgb(245, 158, 11); +} + +.heading { + color: rgb(180, 83, 9); + font-weight: 700; + margin-bottom: 0.5rem; +} + +.body { + color: rgb(146, 64, 14); +} diff --git a/e2e/solid-start/start-manifest/src/styles/shared-widget.module.css b/e2e/solid-start/start-manifest/src/styles/shared-widget.module.css new file mode 100644 index 00000000000..ed197cddb07 --- /dev/null +++ b/e2e/solid-start/start-manifest/src/styles/shared-widget.module.css @@ -0,0 +1,17 @@ +.widget { + margin-top: 1rem; + padding: 1rem; + border-top: 4px solid rgb(249, 115, 22); + border-radius: 0.75rem; + background: rgb(255, 247, 237); +} + +.title { + font-weight: 700; + color: rgb(154, 52, 18); +} + +.content { + margin-top: 0.5rem; + color: rgb(124, 45, 18); +} diff --git a/e2e/solid-start/start-manifest/tests/start-manifest.spec.ts b/e2e/solid-start/start-manifest/tests/start-manifest.spec.ts new file mode 100644 index 00000000000..23244db8287 --- /dev/null +++ b/e2e/solid-start/start-manifest/tests/start-manifest.spec.ts @@ -0,0 +1,453 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import type { Page } from '@playwright/test' +import { readdir } from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const ROOT_SHELL_COLOR = 'rgb(22, 101, 52)' +const ROUTE_ONE_COLOR = 'rgb(30, 64, 175)' +const ROUTE_TWO_COLOR = 'rgb(126, 34, 206)' +const SHARED_CARD_BG = 'rgb(252, 231, 243)' +const SHARED_WIDGET_BG = 'rgb(255, 247, 237)' +const SHARED_WIDGET_BORDER = 'rgb(249, 115, 22)' + +const buildUrl = (baseURL: string, pathname: string) => + baseURL.replace(/\/$/, '') + pathname + +async function getColor(testId: string, page: Page) { + return page + .getByTestId(testId) + .evaluate((element) => getComputedStyle(element).color) +} + +async function getBackgroundColor(testId: string, page: Page) { + return page + .getByTestId(testId) + .evaluate((element) => getComputedStyle(element).backgroundColor) +} + +function getStylesheetHrefsFromHtml(html: string) { + return Array.from( + html.matchAll(/]+rel="stylesheet"[^>]+href="([^"]+)"/g), + (match) => match[1]!, + ) +} + +async function getHeadStylesheetHrefs(page: Page) { + return page.locator('head link[rel="stylesheet"]').evaluateAll((links) => { + return links.map( + (link) => (link as HTMLLinkElement).getAttribute('href') || '', + ) + }) +} + +function countMatchingStylesheetHrefs(hrefs: Array, pattern: string) { + return hrefs.filter((href) => href.includes(pattern)).length +} + +async function loadBuiltStartManifest() { + const serverDir = path.resolve(import.meta.dirname, '../dist/server/assets') + const entries = await readdir(serverDir) + const manifestFile = entries.find( + (entry) => + entry.startsWith('_tanstack-start-manifest_v-') && entry.endsWith('.js'), + ) + + expect(manifestFile).toBeTruthy() + + const moduleUrl = `${pathToFileURL(path.join(serverDir, manifestFile!)).href}?t=${Date.now()}` + const manifestModule = await import(moduleUrl) + + return manifestModule.tsrStartManifest() as { + routes: Record }> + } +} + +async function expectDirectEntry({ + page, + request, + baseURL, + pathname, + expectedVisibleTestId, + expectedAbsentTestId, + expectedStylesheetPattern, + unexpectedStylesheetPattern, + expectedColorTestId, + expectedColor, +}: { + page: Page + request: { + get: (url: string) => Promise<{ ok(): boolean; text(): Promise }> + } + baseURL: string + pathname: string + expectedVisibleTestId: string + expectedAbsentTestId: string + expectedStylesheetPattern: string + unexpectedStylesheetPattern: string + expectedColorTestId: string + expectedColor: string +}) { + const url = buildUrl(baseURL, pathname) + const response = await request.get(url) + const ssrHtml = await response.text() + + expect(response.ok()).toBe(true) + expect(ssrHtml).toContain('root-shell-marker') + expect(ssrHtml).toContain(expectedVisibleTestId) + expect(ssrHtml).not.toContain(expectedAbsentTestId) + + const stylesheetLinks = getStylesheetHrefsFromHtml(ssrHtml) + + expect( + countMatchingStylesheetHrefs(stylesheetLinks, expectedStylesheetPattern), + ).toBe(1) + expect( + countMatchingStylesheetHrefs(stylesheetLinks, unexpectedStylesheetPattern), + ).toBe(0) + expect(stylesheetLinks).toHaveLength(2) + + await page.goto(url) + await expect(page.getByTestId(expectedVisibleTestId)).toBeVisible() + await expect(page.getByTestId('hydration-marker')).toBeVisible() + + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, expectedStylesheetPattern) + }) + .toBe(1) + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, unexpectedStylesheetPattern) + }) + .toBe(0) + + await expect + .poll(() => getColor('root-shell-marker', page)) + .toBe(ROOT_SHELL_COLOR) + await expect + .poll(() => getColor(expectedColorTestId, page)) + .toBe(expectedColor) +} + +test('SSR and client navigation keep CSS module styles correct without hydration errors', async ({ + page, + baseURL, + request, +}) => { + const routeOneUrl = buildUrl(baseURL!, '/r1') + const response = await request.get(routeOneUrl) + const ssrHtml = await response.text() + + expect(response.ok()).toBe(true) + expect(ssrHtml).toContain('root-shell-marker') + expect(ssrHtml).toContain('route-r1-card') + expect(ssrHtml).not.toContain('route-r2-card') + + const stylesheetLinks = getStylesheetHrefsFromHtml(ssrHtml) + + expect(countMatchingStylesheetHrefs(stylesheetLinks, 'r1-')).toBe(1) + expect(countMatchingStylesheetHrefs(stylesheetLinks, 'r2-')).toBe(0) + expect(stylesheetLinks).toHaveLength(2) + + await page.goto(routeOneUrl) + + await expect(page.getByTestId('route-r1-card')).toBeVisible() + await expect(page.getByTestId('hydration-marker')).toBeVisible() + + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, 'r1-') + }) + .toBe(1) + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, 'r2-') + }) + .toBe(0) + + await expect + .poll(() => getColor('root-shell-marker', page)) + .toBe(ROOT_SHELL_COLOR) + await expect.poll(() => getColor('route-r1-card', page)).toBe(ROUTE_ONE_COLOR) + + await page.getByTestId('nav-/r2').click() + await page.waitForURL('**/r2') + await expect(page.getByTestId('route-r2-card')).toBeVisible() + + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, 'r2-') + }) + .toBe(1) + + await expect + .poll(() => getColor('root-shell-marker', page)) + .toBe(ROOT_SHELL_COLOR) + await expect.poll(() => getColor('route-r2-card', page)).toBe(ROUTE_TWO_COLOR) + + await page.getByTestId('nav-/r1').click() + await page.waitForURL('**/r1') + await expect(page.getByTestId('route-r1-card')).toBeVisible() + + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, 'r1-') + }) + .toBe(1) + + await expect + .poll(() => getColor('root-shell-marker', page)) + .toBe(ROOT_SHELL_COLOR) + await expect.poll(() => getColor('route-r1-card', page)).toBe(ROUTE_ONE_COLOR) +}) + +test('direct SSR entry on /r2 only includes its stylesheet and hydrates cleanly', async ({ + page, + baseURL, + request, +}) => { + await expectDirectEntry({ + page, + request, + baseURL: baseURL!, + pathname: '/r2', + expectedVisibleTestId: 'route-r2-card', + expectedAbsentTestId: 'route-r1-card', + expectedStylesheetPattern: 'r2-', + unexpectedStylesheetPattern: 'r1-', + expectedColorTestId: 'route-r2-card', + expectedColor: ROUTE_TWO_COLOR, + }) +}) + +test('direct SSR entries on different route-css pages stay isolated', async ({ + page, + baseURL, + request, +}) => { + await expectDirectEntry({ + page, + request, + baseURL: baseURL!, + pathname: '/r2', + expectedVisibleTestId: 'route-r2-card', + expectedAbsentTestId: 'route-r1-card', + expectedStylesheetPattern: 'r2-', + unexpectedStylesheetPattern: 'r1-', + expectedColorTestId: 'route-r2-card', + expectedColor: ROUTE_TWO_COLOR, + }) + + await expectDirectEntry({ + page, + request, + baseURL: baseURL!, + pathname: '/r1', + expectedVisibleTestId: 'route-r1-card', + expectedAbsentTestId: 'route-r2-card', + expectedStylesheetPattern: 'r1-', + unexpectedStylesheetPattern: 'r2-', + expectedColorTestId: 'route-r1-card', + expectedColor: ROUTE_ONE_COLOR, + }) +}) + +test('home route only renders the root stylesheet and no route-specific CSS', async ({ + page, + baseURL, + request, +}) => { + const homeUrl = buildUrl(baseURL!, '/') + const response = await request.get(homeUrl) + const ssrHtml = await response.text() + + expect(response.ok()).toBe(true) + expect(ssrHtml).toContain('root-shell-marker') + expect(ssrHtml).toContain('home-copy') + expect(ssrHtml).not.toContain('route-r1-card') + expect(ssrHtml).not.toContain('route-r2-card') + + const stylesheetLinks = getStylesheetHrefsFromHtml(ssrHtml) + + expect(countMatchingStylesheetHrefs(stylesheetLinks, 'r1-')).toBe(0) + expect(countMatchingStylesheetHrefs(stylesheetLinks, 'r2-')).toBe(0) + expect(stylesheetLinks).toHaveLength(1) + + await page.goto(homeUrl) + await expect(page.getByTestId('home-copy')).toBeVisible() + await expect(page.getByTestId('hydration-marker')).toBeVisible() + + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, 'r1-') + }) + .toBe(0) + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, 'r2-') + }) + .toBe(0) + + await expect + .poll(() => getColor('root-shell-marker', page)) + .toBe(ROOT_SHELL_COLOR) +}) + +test('built start manifest preserves shared layout asset identity across sibling routes', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/shared-a')) + await expect(page.getByTestId('shared-a-card')).toBeVisible() + + const manifest = await loadBuiltStartManifest() + + const sharedAAsset = manifest.routes['/shared-a']?.assets?.[0] + const sharedBAsset = manifest.routes['/shared-b']?.assets?.[0] + const sharedCAsset = manifest.routes['/shared-c']?.assets?.[0] + + expect(sharedAAsset).toBeTruthy() + expect(sharedAAsset).toBe(sharedBAsset) + expect(sharedBAsset).toBe(sharedCAsset) +}) + +test('shared CSS chunk persists across client-side nav', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/a')) + + await expect(page.getByTestId('page-a')).toBeVisible() + await expect(page.getByTestId('shared-card')).toBeVisible() + await expect + .poll(() => getBackgroundColor('shared-card', page)) + .toBe(SHARED_CARD_BG) + await expect( + page.locator('head link[rel="stylesheet"][href*="SharedCard"]'), + ).toHaveCount(1) + + await page.getByTestId('nav-/b').click() + await page.waitForURL('**/b') + await expect(page.getByTestId('page-b')).toBeVisible() + + await expect( + page.locator('head link[rel="stylesheet"][href*="SharedCard"]'), + ).toHaveCount(1) + await expect + .poll(() => getBackgroundColor('shared-card', page)) + .toBe(SHARED_CARD_BG) +}) + +test('shared widget CSS stays applied when navigating from static to lazy route', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-static')) + + const widget = page.getByTestId('shared-widget') + await expect(widget).toBeVisible() + expect(await getBackgroundColor('shared-widget', page)).toBe(SHARED_WIDGET_BG) + expect( + await widget.evaluate( + (element) => getComputedStyle(element).borderTopColor, + ), + ).toBe(SHARED_WIDGET_BORDER) + + await expect(page.getByTestId('lazy-css-static-hydrated')).toBeVisible() + + await page.getByTestId('nav-/lazy-css-lazy').click() + await page.waitForURL('**/lazy-css-lazy') + await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) +}) + +test('shared widget CSS stays applied when navigating from lazy to static route', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-lazy')) + await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) + + await page.getByTestId('nav-/lazy-css-static').click() + await page.waitForURL('**/lazy-css-static') + + const widget = page.getByTestId('shared-widget') + await expect(widget).toBeVisible() + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) + expect( + await widget.evaluate( + (element) => getComputedStyle(element).borderTopColor, + ), + ).toBe(SHARED_WIDGET_BORDER) +}) + +test('shared widget CSS is applied on direct navigation to lazy route', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-lazy')) + await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() + + const widget = page.getByTestId('shared-widget') + await expect(widget).toBeVisible() + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) + expect( + await widget.evaluate( + (element) => getComputedStyle(element).borderTopColor, + ), + ).toBe(SHARED_WIDGET_BORDER) +}) + +test('shared widget CSS persists after navigating away from lazy and back', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-static')) + await expect(page.getByTestId('lazy-css-static-hydrated')).toBeVisible() + + await page.getByTestId('nav-/lazy-css-lazy').click() + await page.waitForURL('**/lazy-css-lazy') + await expect(page.getByTestId('shared-widget')).toBeVisible() + + await page.getByTestId('nav-home').click() + await page.waitForURL(/\/([^/]*)(\/)?($|\?)/) + + await page.getByTestId('nav-/lazy-css-lazy').click() + await page.waitForURL('**/lazy-css-lazy') + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) +}) diff --git a/e2e/solid-start/start-manifest/tsconfig.json b/e2e/solid-start/start-manifest/tsconfig.json new file mode 100644 index 00000000000..76cf3401fa4 --- /dev/null +++ b/e2e/solid-start/start-manifest/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": "ES2024", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/solid-start/start-manifest/vite.config.ts b/e2e/solid-start/start-manifest/vite.config.ts new file mode 100644 index 00000000000..896fa284f1b --- /dev/null +++ b/e2e/solid-start/start-manifest/vite.config.ts @@ -0,0 +1,13 @@ +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import { defineConfig } from 'vite' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + server: { + port: 3000, + }, + resolve: { + tsconfigPaths: true, + }, + plugins: [tanstackStart({ srcDirectory: 'src' }), viteSolid({ ssr: true })], +}) diff --git a/e2e/vue-start/start-manifest/package.json b/e2e/vue-start/start-manifest/package.json new file mode 100644 index 00000000000..ffe9b414c9a --- /dev/null +++ b/e2e/vue-start/start-manifest/package.json @@ -0,0 +1,33 @@ +{ + "name": "tanstack-vue-start-e2e-start-manifest", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/vue-router": "workspace:^", + "@tanstack/vue-start": "workspace:^", + "vue": "^3.5.16" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@vitejs/plugin-vue-jsx": "^5.1.5", + "srvx": "^0.11.9", + "typescript": "^6.0.2", + "vite": "^8.0.0" + }, + "nx": { + "targets": { + "test:e2e": { + "parallelism": false + } + } + } +} diff --git a/e2e/vue-start/start-manifest/playwright.config.ts b/e2e/vue-start/start-manifest/playwright.config.ts new file mode 100644 index 00000000000..b9f835c16a6 --- /dev/null +++ b/e2e/vue-start/start-manifest/playwright.config.ts @@ -0,0 +1,30 @@ +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']], + + use: { + baseURL, + }, + + webServer: { + command: `pnpm build && PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/vue-start/start-manifest/src/components/AppShell.tsx b/e2e/vue-start/start-manifest/src/components/AppShell.tsx new file mode 100644 index 00000000000..0d57b2d0e34 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/components/AppShell.tsx @@ -0,0 +1,38 @@ +/// +import { ClientOnly, Link, Outlet, linkOptions } from '@tanstack/vue-router' +import styles from '~/styles/root-shell.module.css' + +const ROUTES = linkOptions([ + { to: '/', label: 'home' }, + { to: '/a', label: '/a' }, + { to: '/b', label: '/b' }, + { to: '/lazy-css-static', label: '/lazy-css-static' }, + { to: '/lazy-css-lazy', label: '/lazy-css-lazy' }, + { to: '/r1', label: '/r1' }, + { to: '/r2', label: '/r2' }, + { to: '/shared-a', label: '/shared-a' }, +]) + +export function AppShell() { + return ( +
+ + +
+
+ Start manifest CSS root shell +
+ +
hydrated
+
+ +
+
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/components/SharedCard.tsx b/e2e/vue-start/start-manifest/src/components/SharedCard.tsx new file mode 100644 index 00000000000..c82b2b0ea55 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/components/SharedCard.tsx @@ -0,0 +1,10 @@ +/// +import styles from '~/styles/shared-card.module.css' + +export function SharedCard({ label }: { label: string }) { + return ( +
+ Shared card {label} +
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/components/SharedNestedLayout.tsx b/e2e/vue-start/start-manifest/src/components/SharedNestedLayout.tsx new file mode 100644 index 00000000000..9c1f63f2f97 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/components/SharedNestedLayout.tsx @@ -0,0 +1,34 @@ +/// +import { Link, linkOptions } from '@tanstack/vue-router' +import { defineComponent } from 'vue' +import styles from '~/styles/shared-layout.module.css' + +const ROUTES = linkOptions([ + { to: '/shared-a', label: '/shared-a' }, + { to: '/shared-b', label: '/shared-b' }, + { to: '/shared-c', label: '/shared-c' }, +]) + +export const SharedNestedLayout = defineComponent({ + setup(_, { slots }) { + return () => ( +
+
+ Shared nested layout CSS +
+ + + +
+ {slots.default?.()} +
+
+ ) + }, +}) diff --git a/e2e/vue-start/start-manifest/src/components/SharedWidget.tsx b/e2e/vue-start/start-manifest/src/components/SharedWidget.tsx new file mode 100644 index 00000000000..9074d5b2b0c --- /dev/null +++ b/e2e/vue-start/start-manifest/src/components/SharedWidget.tsx @@ -0,0 +1,15 @@ +/// +import styles from '~/styles/shared-widget.module.css' + +export function SharedWidget() { + return ( +
+
+ Shared widget styles +
+
+ This widget uses CSS shared by a static route and a lazy route. +
+
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/components/SharedWidgetLazy.tsx b/e2e/vue-start/start-manifest/src/components/SharedWidgetLazy.tsx new file mode 100644 index 00000000000..5f1f6410a51 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/components/SharedWidgetLazy.tsx @@ -0,0 +1,5 @@ +import { SharedWidget } from './SharedWidget' + +export default function SharedWidgetLazy() { + return +} diff --git a/e2e/vue-start/start-manifest/src/routeTree.gen.ts b/e2e/vue-start/start-manifest/src/routeTree.gen.ts new file mode 100644 index 00000000000..357d3be62be --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routeTree.gen.ts @@ -0,0 +1,345 @@ +/* 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 SharedCRouteImport } from './routes/shared-c' +import { Route as SharedBRouteImport } from './routes/shared-b' +import { Route as SharedARouteImport } from './routes/shared-a' +import { Route as R6RouteImport } from './routes/r6' +import { Route as R5RouteImport } from './routes/r5' +import { Route as R4RouteImport } from './routes/r4' +import { Route as R3RouteImport } from './routes/r3' +import { Route as R2RouteImport } from './routes/r2' +import { Route as R1RouteImport } from './routes/r1' +import { Route as LazyCssStaticRouteImport } from './routes/lazy-css-static' +import { Route as LazyCssLazyRouteImport } from './routes/lazy-css-lazy' +import { Route as BRouteImport } from './routes/b' +import { Route as ARouteImport } from './routes/a' +import { Route as IndexRouteImport } from './routes/index' + +const SharedCRoute = SharedCRouteImport.update({ + id: '/shared-c', + path: '/shared-c', + getParentRoute: () => rootRouteImport, +} as any) +const SharedBRoute = SharedBRouteImport.update({ + id: '/shared-b', + path: '/shared-b', + getParentRoute: () => rootRouteImport, +} as any) +const SharedARoute = SharedARouteImport.update({ + id: '/shared-a', + path: '/shared-a', + getParentRoute: () => rootRouteImport, +} as any) +const R6Route = R6RouteImport.update({ + id: '/r6', + path: '/r6', + getParentRoute: () => rootRouteImport, +} as any) +const R5Route = R5RouteImport.update({ + id: '/r5', + path: '/r5', + getParentRoute: () => rootRouteImport, +} as any) +const R4Route = R4RouteImport.update({ + id: '/r4', + path: '/r4', + getParentRoute: () => rootRouteImport, +} as any) +const R3Route = R3RouteImport.update({ + id: '/r3', + path: '/r3', + getParentRoute: () => rootRouteImport, +} as any) +const R2Route = R2RouteImport.update({ + id: '/r2', + path: '/r2', + getParentRoute: () => rootRouteImport, +} as any) +const R1Route = R1RouteImport.update({ + id: '/r1', + path: '/r1', + getParentRoute: () => rootRouteImport, +} as any) +const LazyCssStaticRoute = LazyCssStaticRouteImport.update({ + id: '/lazy-css-static', + path: '/lazy-css-static', + getParentRoute: () => rootRouteImport, +} as any) +const LazyCssLazyRoute = LazyCssLazyRouteImport.update({ + id: '/lazy-css-lazy', + path: '/lazy-css-lazy', + getParentRoute: () => rootRouteImport, +} as any) +const BRoute = BRouteImport.update({ + id: '/b', + path: '/b', + getParentRoute: () => rootRouteImport, +} as any) +const ARoute = ARouteImport.update({ + id: '/a', + path: '/a', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/a': typeof ARoute + '/b': typeof BRoute + '/lazy-css-lazy': typeof LazyCssLazyRoute + '/lazy-css-static': typeof LazyCssStaticRoute + '/r1': typeof R1Route + '/r2': typeof R2Route + '/r3': typeof R3Route + '/r4': typeof R4Route + '/r5': typeof R5Route + '/r6': typeof R6Route + '/shared-a': typeof SharedARoute + '/shared-b': typeof SharedBRoute + '/shared-c': typeof SharedCRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/a': typeof ARoute + '/b': typeof BRoute + '/lazy-css-lazy': typeof LazyCssLazyRoute + '/lazy-css-static': typeof LazyCssStaticRoute + '/r1': typeof R1Route + '/r2': typeof R2Route + '/r3': typeof R3Route + '/r4': typeof R4Route + '/r5': typeof R5Route + '/r6': typeof R6Route + '/shared-a': typeof SharedARoute + '/shared-b': typeof SharedBRoute + '/shared-c': typeof SharedCRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/a': typeof ARoute + '/b': typeof BRoute + '/lazy-css-lazy': typeof LazyCssLazyRoute + '/lazy-css-static': typeof LazyCssStaticRoute + '/r1': typeof R1Route + '/r2': typeof R2Route + '/r3': typeof R3Route + '/r4': typeof R4Route + '/r5': typeof R5Route + '/r6': typeof R6Route + '/shared-a': typeof SharedARoute + '/shared-b': typeof SharedBRoute + '/shared-c': typeof SharedCRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/a' + | '/b' + | '/lazy-css-lazy' + | '/lazy-css-static' + | '/r1' + | '/r2' + | '/r3' + | '/r4' + | '/r5' + | '/r6' + | '/shared-a' + | '/shared-b' + | '/shared-c' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/a' + | '/b' + | '/lazy-css-lazy' + | '/lazy-css-static' + | '/r1' + | '/r2' + | '/r3' + | '/r4' + | '/r5' + | '/r6' + | '/shared-a' + | '/shared-b' + | '/shared-c' + id: + | '__root__' + | '/' + | '/a' + | '/b' + | '/lazy-css-lazy' + | '/lazy-css-static' + | '/r1' + | '/r2' + | '/r3' + | '/r4' + | '/r5' + | '/r6' + | '/shared-a' + | '/shared-b' + | '/shared-c' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ARoute: typeof ARoute + BRoute: typeof BRoute + LazyCssLazyRoute: typeof LazyCssLazyRoute + LazyCssStaticRoute: typeof LazyCssStaticRoute + R1Route: typeof R1Route + R2Route: typeof R2Route + R3Route: typeof R3Route + R4Route: typeof R4Route + R5Route: typeof R5Route + R6Route: typeof R6Route + SharedARoute: typeof SharedARoute + SharedBRoute: typeof SharedBRoute + SharedCRoute: typeof SharedCRoute +} + +declare module '@tanstack/vue-router' { + interface FileRoutesByPath { + '/shared-c': { + id: '/shared-c' + path: '/shared-c' + fullPath: '/shared-c' + preLoaderRoute: typeof SharedCRouteImport + parentRoute: typeof rootRouteImport + } + '/shared-b': { + id: '/shared-b' + path: '/shared-b' + fullPath: '/shared-b' + preLoaderRoute: typeof SharedBRouteImport + parentRoute: typeof rootRouteImport + } + '/shared-a': { + id: '/shared-a' + path: '/shared-a' + fullPath: '/shared-a' + preLoaderRoute: typeof SharedARouteImport + parentRoute: typeof rootRouteImport + } + '/r6': { + id: '/r6' + path: '/r6' + fullPath: '/r6' + preLoaderRoute: typeof R6RouteImport + parentRoute: typeof rootRouteImport + } + '/r5': { + id: '/r5' + path: '/r5' + fullPath: '/r5' + preLoaderRoute: typeof R5RouteImport + parentRoute: typeof rootRouteImport + } + '/r4': { + id: '/r4' + path: '/r4' + fullPath: '/r4' + preLoaderRoute: typeof R4RouteImport + parentRoute: typeof rootRouteImport + } + '/r3': { + id: '/r3' + path: '/r3' + fullPath: '/r3' + preLoaderRoute: typeof R3RouteImport + parentRoute: typeof rootRouteImport + } + '/r2': { + id: '/r2' + path: '/r2' + fullPath: '/r2' + preLoaderRoute: typeof R2RouteImport + parentRoute: typeof rootRouteImport + } + '/r1': { + id: '/r1' + path: '/r1' + fullPath: '/r1' + preLoaderRoute: typeof R1RouteImport + parentRoute: typeof rootRouteImport + } + '/lazy-css-static': { + id: '/lazy-css-static' + path: '/lazy-css-static' + fullPath: '/lazy-css-static' + preLoaderRoute: typeof LazyCssStaticRouteImport + parentRoute: typeof rootRouteImport + } + '/lazy-css-lazy': { + id: '/lazy-css-lazy' + path: '/lazy-css-lazy' + fullPath: '/lazy-css-lazy' + preLoaderRoute: typeof LazyCssLazyRouteImport + parentRoute: typeof rootRouteImport + } + '/b': { + id: '/b' + path: '/b' + fullPath: '/b' + preLoaderRoute: typeof BRouteImport + parentRoute: typeof rootRouteImport + } + '/a': { + id: '/a' + path: '/a' + fullPath: '/a' + preLoaderRoute: typeof ARouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ARoute: ARoute, + BRoute: BRoute, + LazyCssLazyRoute: LazyCssLazyRoute, + LazyCssStaticRoute: LazyCssStaticRoute, + R1Route: R1Route, + R2Route: R2Route, + R3Route: R3Route, + R4Route: R4Route, + R5Route: R5Route, + R6Route: R6Route, + SharedARoute: SharedARoute, + SharedBRoute: SharedBRoute, + SharedCRoute: SharedCRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/vue-start' +declare module '@tanstack/vue-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/vue-start/start-manifest/src/router.tsx b/e2e/vue-start/start-manifest/src/router.tsx new file mode 100644 index 00000000000..d7cd8e82847 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/router.tsx @@ -0,0 +1,18 @@ +import { createRouter } from '@tanstack/vue-router' +import { routeTree } from './routeTree.gen' + +export const getRouter = () => { + const router = createRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: 'intent', + }) + + return router +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/e2e/vue-start/start-manifest/src/routes/__root.tsx b/e2e/vue-start/start-manifest/src/routes/__root.tsx new file mode 100644 index 00000000000..b62d37c0f05 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/__root.tsx @@ -0,0 +1,35 @@ +/// +import { + Body, + HeadContent, + Html, + Scripts, + createRootRoute, +} from '@tanstack/vue-router' +import { AppShell } from '~/components/AppShell' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'TanStack Start Manifest Bloat E2E' }, + ], + }), + component: AppShell, + shellComponent: RootDocument, +}) + +function RootDocument(_: unknown, { slots }: { slots: any }) { + return ( + + + + + + {slots.default?.()} + + + + ) +} diff --git a/e2e/vue-start/start-manifest/src/routes/a.tsx b/e2e/vue-start/start-manifest/src/routes/a.tsx new file mode 100644 index 00000000000..e53399273da --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/a.tsx @@ -0,0 +1,18 @@ +/// +import { createFileRoute } from '@tanstack/vue-router' +import { SharedCard } from '~/components/SharedCard' +import styles from '~/styles/page-a.module.css' + +export const Route = createFileRoute('/a')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Route /a

+

Route /a keeps the shared card styled.

+ +
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/routes/b.tsx b/e2e/vue-start/start-manifest/src/routes/b.tsx new file mode 100644 index 00000000000..cb989a257f8 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/b.tsx @@ -0,0 +1,18 @@ +/// +import { createFileRoute } from '@tanstack/vue-router' +import { SharedCard } from '~/components/SharedCard' +import styles from '~/styles/page-b.module.css' + +export const Route = createFileRoute('/b')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Route /b

+

Route /b should keep the shared card stylesheet after nav.

+ +
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/routes/index.tsx b/e2e/vue-start/start-manifest/src/routes/index.tsx new file mode 100644 index 00000000000..728cc55e881 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/index.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/')({ + component: HomeRoute, +}) + +function HomeRoute() { + return ( +
+

Start manifest fixture

+

+ Use this page to navigate between CSS module routes. +

+
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/routes/lazy-css-lazy.tsx b/e2e/vue-start/start-manifest/src/routes/lazy-css-lazy.tsx new file mode 100644 index 00000000000..0db0845425d --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/lazy-css-lazy.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { Suspense, defineAsyncComponent } from 'vue' + +const LazySharedWidget = defineAsyncComponent( + () => import('~/components/SharedWidgetLazy'), +) + +export const Route = createFileRoute('/lazy-css-lazy')({ + component: LazyCssLazyRoute, +}) + +function LazyCssLazyRoute() { + return ( +
+

Lazy CSS Repro - Lazy Route

+

+ This route renders the same widget through a lazy component so the CSS + only exists behind a dynamic import boundary. +

+ + + {{ + default: () => , + fallback: () => ( +
Loading...
+ ), + }} +
+
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/routes/lazy-css-static.tsx b/e2e/vue-start/start-manifest/src/routes/lazy-css-static.tsx new file mode 100644 index 00000000000..2a8352e67e2 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/lazy-css-static.tsx @@ -0,0 +1,24 @@ +import { ClientOnly, createFileRoute } from '@tanstack/vue-router' +import { SharedWidget } from '~/components/SharedWidget' + +export const Route = createFileRoute('/lazy-css-static')({ + component: LazyCssStaticRoute, +}) + +function LazyCssStaticRoute() { + return ( +
+

Lazy CSS Repro - Static Route

+

+ This route statically imports the shared widget so its CSS is present in + the SSR head. +

+ + +
hydrated
+
+ + +
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/routes/r1.tsx b/e2e/vue-start/start-manifest/src/routes/r1.tsx new file mode 100644 index 00000000000..b35bec54e3d --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/r1.tsx @@ -0,0 +1,18 @@ +/// +import { createFileRoute } from '@tanstack/vue-router' +import styles from '~/styles/route-one.module.css' + +export const Route = createFileRoute('/r1')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Route /r1

+
+ Route /r1 CSS module styling +
+
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/routes/r2.tsx b/e2e/vue-start/start-manifest/src/routes/r2.tsx new file mode 100644 index 00000000000..eadd952f98a --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/r2.tsx @@ -0,0 +1,18 @@ +/// +import { createFileRoute } from '@tanstack/vue-router' +import styles from '~/styles/route-two.module.css' + +export const Route = createFileRoute('/r2')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Route /r2

+
+ Route /r2 CSS module styling +
+
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/routes/r3.tsx b/e2e/vue-start/start-manifest/src/routes/r3.tsx new file mode 100644 index 00000000000..a5a486cfaa3 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/r3.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/r3')({ + component: () =>
Route /r3
, +}) diff --git a/e2e/vue-start/start-manifest/src/routes/r4.tsx b/e2e/vue-start/start-manifest/src/routes/r4.tsx new file mode 100644 index 00000000000..9739966d024 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/r4.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/r4')({ + component: () =>
Route /r4
, +}) diff --git a/e2e/vue-start/start-manifest/src/routes/r5.tsx b/e2e/vue-start/start-manifest/src/routes/r5.tsx new file mode 100644 index 00000000000..7c081e29a69 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/r5.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/r5')({ + component: () =>
Route /r5
, +}) diff --git a/e2e/vue-start/start-manifest/src/routes/r6.tsx b/e2e/vue-start/start-manifest/src/routes/r6.tsx new file mode 100644 index 00000000000..c56c2afda3f --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/r6.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/r6')({ + component: () =>
Route /r6
, +}) diff --git a/e2e/vue-start/start-manifest/src/routes/shared-a.tsx b/e2e/vue-start/start-manifest/src/routes/shared-a.tsx new file mode 100644 index 00000000000..e7253a94ac9 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/shared-a.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { SharedNestedLayout } from '~/components/SharedNestedLayout' + +export const Route = createFileRoute('/shared-a')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( + +
Shared route A
+
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/routes/shared-b.tsx b/e2e/vue-start/start-manifest/src/routes/shared-b.tsx new file mode 100644 index 00000000000..ed02b3429bd --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/shared-b.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { SharedNestedLayout } from '~/components/SharedNestedLayout' + +export const Route = createFileRoute('/shared-b')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( + +
Shared route B
+
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/routes/shared-c.tsx b/e2e/vue-start/start-manifest/src/routes/shared-c.tsx new file mode 100644 index 00000000000..e395b33ed5b --- /dev/null +++ b/e2e/vue-start/start-manifest/src/routes/shared-c.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { SharedNestedLayout } from '~/components/SharedNestedLayout' + +export const Route = createFileRoute('/shared-c')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( + +
Shared route C
+
+ ) +} diff --git a/e2e/vue-start/start-manifest/src/styles/page-a.module.css b/e2e/vue-start/start-manifest/src/styles/page-a.module.css new file mode 100644 index 00000000000..7ed4301097f --- /dev/null +++ b/e2e/vue-start/start-manifest/src/styles/page-a.module.css @@ -0,0 +1,10 @@ +.page { + padding: 1rem; + min-height: 12rem; + background: rgb(240, 253, 244); + border: 4px solid rgb(22, 163, 74); +} + +.title { + color: rgb(21, 128, 61); +} diff --git a/e2e/vue-start/start-manifest/src/styles/page-b.module.css b/e2e/vue-start/start-manifest/src/styles/page-b.module.css new file mode 100644 index 00000000000..93d1d39a014 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/styles/page-b.module.css @@ -0,0 +1,10 @@ +.page { + padding: 1rem; + min-height: 12rem; + background: rgb(239, 246, 255); + border: 4px solid rgb(37, 99, 235); +} + +.title { + color: rgb(29, 78, 216); +} diff --git a/e2e/vue-start/start-manifest/src/styles/root-shell.module.css b/e2e/vue-start/start-manifest/src/styles/root-shell.module.css new file mode 100644 index 00000000000..e7bece86643 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/styles/root-shell.module.css @@ -0,0 +1,37 @@ +.shell { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + min-height: 100vh; + background: rgb(248, 250, 252); + color: rgb(15, 23, 42); +} + +.nav { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + padding: 1rem; + border-bottom: 1px solid rgb(226, 232, 240); + background: white; +} + +.nav a { + color: rgb(37, 99, 235); + text-decoration: none; +} + +.content { + max-width: 760px; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; +} + +.rootBadge { + display: inline-block; + margin-bottom: 1rem; + padding: 0.375rem 0.75rem; + border-radius: 9999px; + background: rgb(220, 252, 231); + color: rgb(22, 101, 52); + font-weight: 700; +} diff --git a/e2e/vue-start/start-manifest/src/styles/route-one.module.css b/e2e/vue-start/start-manifest/src/styles/route-one.module.css new file mode 100644 index 00000000000..623d03e6a34 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/styles/route-one.module.css @@ -0,0 +1,7 @@ +.card { + padding: 1rem; + border-radius: 0.75rem; + background: rgb(239, 246, 255); + color: rgb(30, 64, 175); + border: 2px solid rgb(96, 165, 250); +} diff --git a/e2e/vue-start/start-manifest/src/styles/route-two.module.css b/e2e/vue-start/start-manifest/src/styles/route-two.module.css new file mode 100644 index 00000000000..66ed3557119 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/styles/route-two.module.css @@ -0,0 +1,7 @@ +.card { + padding: 1rem; + border-radius: 0.75rem; + background: rgb(250, 245, 255); + color: rgb(126, 34, 206); + border: 2px solid rgb(192, 132, 252); +} diff --git a/e2e/vue-start/start-manifest/src/styles/shared-card.module.css b/e2e/vue-start/start-manifest/src/styles/shared-card.module.css new file mode 100644 index 00000000000..2aa5a6ac17f --- /dev/null +++ b/e2e/vue-start/start-manifest/src/styles/shared-card.module.css @@ -0,0 +1,8 @@ +.card { + margin-top: 1rem; + padding: 1.5rem; + border-radius: 0.75rem; + background: rgb(252, 231, 243); + border: 2px solid rgb(190, 24, 93); + color: rgb(157, 23, 77); +} diff --git a/e2e/vue-start/start-manifest/src/styles/shared-layout.module.css b/e2e/vue-start/start-manifest/src/styles/shared-layout.module.css new file mode 100644 index 00000000000..6025a1da066 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/styles/shared-layout.module.css @@ -0,0 +1,16 @@ +.layout { + padding: 1rem; + border-radius: 0.75rem; + background: rgb(255, 251, 235); + border: 2px solid rgb(245, 158, 11); +} + +.heading { + color: rgb(180, 83, 9); + font-weight: 700; + margin-bottom: 0.5rem; +} + +.body { + color: rgb(146, 64, 14); +} diff --git a/e2e/vue-start/start-manifest/src/styles/shared-widget.module.css b/e2e/vue-start/start-manifest/src/styles/shared-widget.module.css new file mode 100644 index 00000000000..ed197cddb07 --- /dev/null +++ b/e2e/vue-start/start-manifest/src/styles/shared-widget.module.css @@ -0,0 +1,17 @@ +.widget { + margin-top: 1rem; + padding: 1rem; + border-top: 4px solid rgb(249, 115, 22); + border-radius: 0.75rem; + background: rgb(255, 247, 237); +} + +.title { + font-weight: 700; + color: rgb(154, 52, 18); +} + +.content { + margin-top: 0.5rem; + color: rgb(124, 45, 18); +} diff --git a/e2e/vue-start/start-manifest/tests/start-manifest.spec.ts b/e2e/vue-start/start-manifest/tests/start-manifest.spec.ts new file mode 100644 index 00000000000..6d13e25764b --- /dev/null +++ b/e2e/vue-start/start-manifest/tests/start-manifest.spec.ts @@ -0,0 +1,457 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import type { Page } from '@playwright/test' +import { readdir } from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const ROOT_SHELL_COLOR = 'rgb(22, 101, 52)' +const ROUTE_ONE_COLOR = 'rgb(30, 64, 175)' +const ROUTE_TWO_COLOR = 'rgb(126, 34, 206)' +const SHARED_CARD_BG = 'rgb(252, 231, 243)' +const SHARED_WIDGET_BG = 'rgb(255, 247, 237)' +const SHARED_WIDGET_BORDER = 'rgb(249, 115, 22)' + +const buildUrl = (baseURL: string, pathname: string) => + baseURL.replace(/\/$/, '') + pathname + +async function getColor(testId: string, page: Page) { + return page + .getByTestId(testId) + .evaluate((element) => getComputedStyle(element).color) +} + +async function getBackgroundColor(testId: string, page: Page) { + return page + .getByTestId(testId) + .evaluate((element) => getComputedStyle(element).backgroundColor) +} + +function getStylesheetHrefsFromHtml(html: string) { + return Array.from( + html.matchAll(/]+rel="stylesheet"[^>]+href="([^"]+)"/g), + (match) => match[1]!, + ) +} + +async function getHeadStylesheetHrefs(page: Page) { + return page.locator('head link[rel="stylesheet"]').evaluateAll((links) => { + return links.map( + (link) => (link as HTMLLinkElement).getAttribute('href') || '', + ) + }) +} + +function hasMatchingStylesheetHref(hrefs: Array, pattern: string) { + return hrefs.some((href) => href.includes(pattern)) +} + +function countMatchingStylesheetHrefs(hrefs: Array, pattern: string) { + return hrefs.filter((href) => href.includes(pattern)).length +} + +async function loadBuiltStartManifest() { + const serverDir = path.resolve(import.meta.dirname, '../dist/server/assets') + const entries = await readdir(serverDir) + const manifestFile = entries.find( + (entry) => + entry.startsWith('_tanstack-start-manifest_v-') && entry.endsWith('.js'), + ) + + expect(manifestFile).toBeTruthy() + + const moduleUrl = `${pathToFileURL(path.join(serverDir, manifestFile!)).href}?t=${Date.now()}` + const manifestModule = await import(moduleUrl) + + return manifestModule.tsrStartManifest() as { + routes: Record }> + } +} + +async function expectDirectEntry({ + page, + request, + baseURL, + pathname, + expectedVisibleTestId, + expectedAbsentTestId, + expectedStylesheetPattern, + unexpectedStylesheetPattern, + expectedColorTestId, + expectedColor, +}: { + page: Page + request: { + get: (url: string) => Promise<{ ok(): boolean; text(): Promise }> + } + baseURL: string + pathname: string + expectedVisibleTestId: string + expectedAbsentTestId: string + expectedStylesheetPattern: string + unexpectedStylesheetPattern: string + expectedColorTestId: string + expectedColor: string +}) { + const url = buildUrl(baseURL, pathname) + const response = await request.get(url) + const ssrHtml = await response.text() + + expect(response.ok()).toBe(true) + expect(ssrHtml).toContain('root-shell-marker') + expect(ssrHtml).toContain(expectedVisibleTestId) + expect(ssrHtml).not.toContain(expectedAbsentTestId) + + const stylesheetLinks = getStylesheetHrefsFromHtml(ssrHtml) + + expect( + countMatchingStylesheetHrefs(stylesheetLinks, expectedStylesheetPattern), + ).toBe(1) + expect( + countMatchingStylesheetHrefs(stylesheetLinks, unexpectedStylesheetPattern), + ).toBe(0) + expect(stylesheetLinks).toHaveLength(2) + + await page.goto(url) + await expect(page.getByTestId(expectedVisibleTestId)).toBeVisible() + await expect(page.getByTestId('hydration-marker')).toBeVisible() + + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return hasMatchingStylesheetHref(hrefs, expectedStylesheetPattern) + }) + .toBe(true) + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, unexpectedStylesheetPattern) + }) + .toBe(0) + + await expect + .poll(() => getColor('root-shell-marker', page)) + .toBe(ROOT_SHELL_COLOR) + await expect + .poll(() => getColor(expectedColorTestId, page)) + .toBe(expectedColor) +} + +test('SSR and client navigation keep CSS module styles correct without hydration errors', async ({ + page, + baseURL, + request, +}) => { + const routeOneUrl = buildUrl(baseURL!, '/r1') + const response = await request.get(routeOneUrl) + const ssrHtml = await response.text() + + expect(response.ok()).toBe(true) + expect(ssrHtml).toContain('root-shell-marker') + expect(ssrHtml).toContain('route-r1-card') + expect(ssrHtml).not.toContain('route-r2-card') + + const stylesheetLinks = getStylesheetHrefsFromHtml(ssrHtml) + + expect(countMatchingStylesheetHrefs(stylesheetLinks, 'r1-')).toBe(1) + expect(countMatchingStylesheetHrefs(stylesheetLinks, 'r2-')).toBe(0) + expect(stylesheetLinks).toHaveLength(2) + + await page.goto(routeOneUrl) + + await expect(page.getByTestId('route-r1-card')).toBeVisible() + await expect(page.getByTestId('hydration-marker')).toBeVisible() + + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return hasMatchingStylesheetHref(hrefs, 'r1-') + }) + .toBe(true) + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, 'r2-') + }) + .toBe(0) + + await expect + .poll(() => getColor('root-shell-marker', page)) + .toBe(ROOT_SHELL_COLOR) + await expect.poll(() => getColor('route-r1-card', page)).toBe(ROUTE_ONE_COLOR) + + await page.getByTestId('nav-/r2').click() + await page.waitForURL('**/r2') + await expect(page.getByTestId('route-r2-card')).toBeVisible() + + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return hasMatchingStylesheetHref(hrefs, 'r2-') + }) + .toBe(true) + + await expect + .poll(() => getColor('root-shell-marker', page)) + .toBe(ROOT_SHELL_COLOR) + await expect.poll(() => getColor('route-r2-card', page)).toBe(ROUTE_TWO_COLOR) + + await page.getByTestId('nav-/r1').click() + await page.waitForURL('**/r1') + await expect(page.getByTestId('route-r1-card')).toBeVisible() + + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return hasMatchingStylesheetHref(hrefs, 'r1-') + }) + .toBe(true) + + await expect + .poll(() => getColor('root-shell-marker', page)) + .toBe(ROOT_SHELL_COLOR) + await expect.poll(() => getColor('route-r1-card', page)).toBe(ROUTE_ONE_COLOR) +}) + +test('direct SSR entry on /r2 only includes its stylesheet and hydrates cleanly', async ({ + page, + baseURL, + request, +}) => { + await expectDirectEntry({ + page, + request, + baseURL: baseURL!, + pathname: '/r2', + expectedVisibleTestId: 'route-r2-card', + expectedAbsentTestId: 'route-r1-card', + expectedStylesheetPattern: 'r2-', + unexpectedStylesheetPattern: 'r1-', + expectedColorTestId: 'route-r2-card', + expectedColor: ROUTE_TWO_COLOR, + }) +}) + +test('direct SSR entries on different route-css pages stay isolated', async ({ + page, + baseURL, + request, +}) => { + await expectDirectEntry({ + page, + request, + baseURL: baseURL!, + pathname: '/r2', + expectedVisibleTestId: 'route-r2-card', + expectedAbsentTestId: 'route-r1-card', + expectedStylesheetPattern: 'r2-', + unexpectedStylesheetPattern: 'r1-', + expectedColorTestId: 'route-r2-card', + expectedColor: ROUTE_TWO_COLOR, + }) + + await expectDirectEntry({ + page, + request, + baseURL: baseURL!, + pathname: '/r1', + expectedVisibleTestId: 'route-r1-card', + expectedAbsentTestId: 'route-r2-card', + expectedStylesheetPattern: 'r1-', + unexpectedStylesheetPattern: 'r2-', + expectedColorTestId: 'route-r1-card', + expectedColor: ROUTE_ONE_COLOR, + }) +}) + +test('home route only renders the root stylesheet and no route-specific CSS', async ({ + page, + baseURL, + request, +}) => { + const homeUrl = buildUrl(baseURL!, '/') + const response = await request.get(homeUrl) + const ssrHtml = await response.text() + + expect(response.ok()).toBe(true) + expect(ssrHtml).toContain('root-shell-marker') + expect(ssrHtml).toContain('home-copy') + expect(ssrHtml).not.toContain('route-r1-card') + expect(ssrHtml).not.toContain('route-r2-card') + + const stylesheetLinks = getStylesheetHrefsFromHtml(ssrHtml) + + expect(countMatchingStylesheetHrefs(stylesheetLinks, 'r1-')).toBe(0) + expect(countMatchingStylesheetHrefs(stylesheetLinks, 'r2-')).toBe(0) + expect(stylesheetLinks).toHaveLength(1) + + await page.goto(homeUrl) + await expect(page.getByTestId('home-copy')).toBeVisible() + await expect(page.getByTestId('hydration-marker')).toBeVisible() + + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, 'r1-') + }) + .toBe(0) + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return countMatchingStylesheetHrefs(hrefs, 'r2-') + }) + .toBe(0) + + await expect + .poll(() => getColor('root-shell-marker', page)) + .toBe(ROOT_SHELL_COLOR) +}) + +test('built start manifest preserves shared layout asset identity across sibling routes', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/shared-a')) + await expect(page.getByTestId('shared-a-card')).toBeVisible() + + const manifest = await loadBuiltStartManifest() + + const sharedAAsset = manifest.routes['/shared-a']?.assets?.[0] + const sharedBAsset = manifest.routes['/shared-b']?.assets?.[0] + const sharedCAsset = manifest.routes['/shared-c']?.assets?.[0] + + expect(sharedAAsset).toBeTruthy() + expect(sharedAAsset).toBe(sharedBAsset) + expect(sharedBAsset).toBe(sharedCAsset) +}) + +test('shared CSS chunk persists across client-side nav', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/a')) + + await expect(page.getByTestId('page-a')).toBeVisible() + await expect(page.getByTestId('shared-card')).toBeVisible() + await expect + .poll(() => getBackgroundColor('shared-card', page)) + .toBe(SHARED_CARD_BG) + await expect( + page.locator('head link[rel="stylesheet"][href*="SharedCard"]'), + ).toHaveCount(1) + + await page.getByTestId('nav-/b').click() + await page.waitForURL('**/b') + await expect(page.getByTestId('page-b')).toBeVisible() + + await expect( + page.locator('head link[rel="stylesheet"][href*="SharedCard"]'), + ).toHaveCount(1) + await expect + .poll(() => getBackgroundColor('shared-card', page)) + .toBe(SHARED_CARD_BG) +}) + +test('shared widget CSS stays applied when navigating from static to lazy route', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-static')) + + const widget = page.getByTestId('shared-widget') + await expect(widget).toBeVisible() + expect(await getBackgroundColor('shared-widget', page)).toBe(SHARED_WIDGET_BG) + expect( + await widget.evaluate( + (element) => getComputedStyle(element).borderTopColor, + ), + ).toBe(SHARED_WIDGET_BORDER) + + await expect(page.getByTestId('lazy-css-static-hydrated')).toBeVisible() + + await page.getByTestId('nav-/lazy-css-lazy').click() + await page.waitForURL('**/lazy-css-lazy') + await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) +}) + +test('shared widget CSS stays applied when navigating from lazy to static route', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-lazy')) + await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) + + await page.getByTestId('nav-/lazy-css-static').click() + await page.waitForURL('**/lazy-css-static') + + const widget = page.getByTestId('shared-widget') + await expect(widget).toBeVisible() + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) + expect( + await widget.evaluate( + (element) => getComputedStyle(element).borderTopColor, + ), + ).toBe(SHARED_WIDGET_BORDER) +}) + +test('shared widget CSS is applied on direct navigation to lazy route', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-lazy')) + await expect(page.getByTestId('lazy-css-lazy-heading')).toBeVisible() + + const widget = page.getByTestId('shared-widget') + await expect(widget).toBeVisible() + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) + expect( + await widget.evaluate( + (element) => getComputedStyle(element).borderTopColor, + ), + ).toBe(SHARED_WIDGET_BORDER) +}) + +test('shared widget CSS persists after navigating away from lazy and back', async ({ + page, + baseURL, +}) => { + await page.goto(buildUrl(baseURL!, '/lazy-css-static')) + await expect(page.getByTestId('lazy-css-static-hydrated')).toBeVisible() + + await page.getByTestId('nav-/lazy-css-lazy').click() + await page.waitForURL('**/lazy-css-lazy') + await expect(page.getByTestId('shared-widget')).toBeVisible() + + await page.getByTestId('nav-home').click() + await page.waitForURL(/\/([^/]*)(\/)?($|\?)/) + + await page.getByTestId('nav-/lazy-css-lazy').click() + await page.waitForURL('**/lazy-css-lazy') + + await expect + .poll(() => getBackgroundColor('shared-widget', page), { + timeout: 5_000, + }) + .toBe(SHARED_WIDGET_BG) +}) diff --git a/e2e/vue-start/start-manifest/tsconfig.json b/e2e/vue-start/start-manifest/tsconfig.json new file mode 100644 index 00000000000..44a68ebf564 --- /dev/null +++ b/e2e/vue-start/start-manifest/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "vue", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2024", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/vue-start/start-manifest/vite.config.ts b/e2e/vue-start/start-manifest/vite.config.ts new file mode 100644 index 00000000000..f1102cffabc --- /dev/null +++ b/e2e/vue-start/start-manifest/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/vue-start/plugin/vite' +import vueJsx from '@vitejs/plugin-vue-jsx' + +export default defineConfig({ + server: { + port: 3000, + }, + resolve: { + tsconfigPaths: true, + }, + plugins: [tanstackStart({ srcDirectory: 'src' }), vueJsx()], +}) diff --git a/packages/react-router/src/Asset.tsx b/packages/react-router/src/Asset.tsx index a1018ac93bc..5ad2e015162 100644 --- a/packages/react-router/src/Asset.tsx +++ b/packages/react-router/src/Asset.tsx @@ -28,7 +28,17 @@ export function Asset({ case 'meta': return case 'link': - return + return ( + + ) case 'style': return (