From f1c88781aff2a3fe296f1b46883dae52baaaac3f Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 24 May 2026 01:46:03 +0200 Subject: [PATCH 01/10] feat(start): support rsbuild iife client output --- .changeset/gentle-pandas-clap.md | 15 ++ docs/router/guide/document-head-management.md | 4 +- .../framework/react/guide/cdn-asset-urls.md | 33 ++-- .../framework/react/guide/early-hints.md | 1 + .../basic-file-based/src/routeTree.gen.ts | 4 +- e2e/react-start/basic/package.json | 8 + e2e/react-start/basic/playwright.config.ts | 3 + e2e/react-start/basic/start-mode-config.ts | 19 ++ .../basic/tests/client-output.spec.ts | 32 ++++ .../src/routes/__root.tsx | 2 +- .../transform-asset-urls/src/server.ts | 2 +- .../tests/transform-asset-urls.spec.ts | 20 +- packages/react-router/src/Asset.tsx | 32 +++- packages/react-router/src/Scripts.tsx | 46 +++-- .../react-router/src/headContentUtils.tsx | 24 ++- packages/react-router/tests/Scripts.test.tsx | 116 +++++++++++- packages/router-core/src/index.ts | 3 + packages/router-core/src/manifest.ts | 35 +++- packages/router-core/src/ssr/ssr-server.ts | 3 + packages/router-core/tests/hydrate.test.ts | 24 ++- .../tests/ssr-server-manifest.test.ts | 22 +++ packages/solid-router/src/Scripts.tsx | 76 +++++--- .../solid-router/src/headContentUtils.tsx | 13 +- packages/solid-router/tests/Scripts.test.tsx | 50 ++++- .../start-plugin-core/src/rsbuild/planning.ts | 18 +- .../start-plugin-core/src/rsbuild/plugin.ts | 8 +- .../start-plugin-core/src/rsbuild/schema.ts | 18 +- .../src/rsbuild/virtual-modules.ts | 28 ++- .../start-manifest-plugin/manifestBuilder.ts | 12 +- .../tests/rsbuild-output-directory.test.ts | 79 +++++++- .../manifestBuilder.test.ts | 30 +++ packages/start-server-core/src/early-hints.ts | 12 +- .../start-server-core/src/router-manifest.ts | 34 ++-- .../src/transformAssetUrls.ts | 138 +++++++++----- .../tests/early-hints.test.ts | 28 +++ .../tests/finalManifest.test.ts | 8 +- .../tests/transformAssets.test.ts | 170 +++++++++++++++-- packages/vue-router/src/Scripts.tsx | 176 ++++++++++-------- packages/vue-router/src/headContentUtils.tsx | 13 +- packages/vue-router/tests/Scripts.test.tsx | 50 ++++- 40 files changed, 1114 insertions(+), 295 deletions(-) create mode 100644 .changeset/gentle-pandas-clap.md create mode 100644 e2e/react-start/basic/tests/client-output.spec.ts diff --git a/.changeset/gentle-pandas-clap.md b/.changeset/gentle-pandas-clap.md new file mode 100644 index 0000000000..e170c7fe85 --- /dev/null +++ b/.changeset/gentle-pandas-clap.md @@ -0,0 +1,15 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +'@tanstack/start-plugin-core': patch +'@tanstack/start-server-core': patch +'@tanstack/react-start': patch +'@tanstack/solid-start': patch +'@tanstack/vue-start': patch +--- + +Add support for Rsbuild client output formats, including module output by default and IIFE output for classic script environments. + +Client entry scripts and preloads are now represented as root route manifest assets, script preloads follow the manifest script format, and script asset cross-origin configuration uses the `script` key. The `transformAssets` script callback context now exposes only `kind: 'script'` and `url`, keeping script format handling internal to manifest rendering. diff --git a/docs/router/guide/document-head-management.md b/docs/router/guide/document-head-management.md index 381462e20a..61770b3662 100644 --- a/docs/router/guide/document-head-management.md +++ b/docs/router/guide/document-head-management.md @@ -69,14 +69,14 @@ The `` component is **required** to render the head, title, meta, It should be **rendered either in the `` tag of your root layout or as high up in the component tree as possible** if your application doesn't or can't manage the `` tag. For manifest-managed assets, you can also set `crossorigin` values on emitted -`modulepreload` and stylesheet links: +script preload and stylesheet links: ```tsx diff --git a/docs/start/framework/react/guide/cdn-asset-urls.md b/docs/start/framework/react/guide/cdn-asset-urls.md index 183921fc6d..df43906198 100644 --- a/docs/start/framework/react/guide/cdn-asset-urls.md +++ b/docs/start/framework/react/guide/cdn-asset-urls.md @@ -15,9 +15,9 @@ This guide is about asset URL rewriting. For choosing CSS import patterns and co The `transformAssets` option on `createStartHandler` rewrites URLs that Start manages in its SSR manifest: -- `` tags for JavaScript preloads +- JavaScript preload links (`` for module output, or `` for IIFE output) - `` tags for manifest-managed CSS -- The client entry module URL +- The client entry script URL - `url(...)` and `@import` URLs inside [inlined CSS](./css-styling#inline-route-css-in-production) when CSS URL templates are enabled It does not rewrite every URL in your app. In particular, it does not rewrite arbitrary route `head().links` entries, including CSS imported with `?url` and returned from route `head()` functions. See [What This Does Not Rewrite](#what-this-does-not-rewrite) for the main exclusions. @@ -75,7 +75,7 @@ export default createServerEntry({ fetch: handler }) transformAssets: { prefix: 'https://cdn.example.com', crossOrigin: { - modulepreload: 'anonymous', + script: 'anonymous', stylesheet: 'use-credentials', }, } @@ -94,13 +94,13 @@ or: ```tsx ``` -If both `transformAssets` and `assetCrossOrigin` set a cross-origin value, `assetCrossOrigin` overrides the value from `transformAssets`. `assetCrossOrigin` only applies to manifest-managed `modulepreload` and stylesheet links, not arbitrary links returned from route `head()` functions. +If both `transformAssets` and `assetCrossOrigin` set a cross-origin value, `assetCrossOrigin` overrides the value from `transformAssets`. `assetCrossOrigin` only applies to manifest-managed script and stylesheet links, not arbitrary links returned from route `head()` functions. ## Use a Callback for Per-Asset Logic @@ -119,7 +119,7 @@ const handler = createStartHandler({ transformAssets: (asset) => { const href = `https://cdn.example.com${asset.url}` - if (asset.kind === 'modulepreload') { + if (asset.kind === 'script') { return { href, crossOrigin: 'anonymous', @@ -135,16 +135,15 @@ export default createServerEntry({ fetch: handler }) The `kind` field tells you which asset URL is being transformed. -| `kind` | Description | -| ----------------- | ---------------------------------------------- | -| `'modulepreload'` | JavaScript module preload URL | -| `'stylesheet'` | Manifest-managed CSS stylesheet URL | -| `'clientEntry'` | Client entry module URL | -| `'css-url'` | `url(...)` or `@import` URL inside inlined CSS | +| `kind` | Description | +| -------------- | ---------------------------------------------- | +| `'script'` | JavaScript preload or client entry script URL | +| `'stylesheet'` | Manifest-managed CSS stylesheet URL | +| `'css-url'` | `url(...)` or `@import` URL inside inlined CSS | For `kind === 'css-url'`, the context also includes `stylesheetHref`, which is the manifest stylesheet href whose CSS content is being inlined. -`crossOrigin` applies to manifest-managed link tags. For the client entry and CSS-internal URLs, returning `{ href }` is equivalent to returning a string. +`crossOrigin` applies to manifest-managed script and stylesheet tags. For CSS-internal URLs, returning `{ href }` is equivalent to returning a string. By default, callback results are cached after the first request in production. Use the object form with `cache: false` only when the transform depends on per-request data. @@ -171,7 +170,7 @@ const handler = createStartHandler({ ? 'https://cdn-eu.example.com' : 'https://cdn-us.example.com' - if (kind === 'modulepreload') { + if (kind === 'script') { return { href: `${cdnBase}${url}`, crossOrigin: 'anonymous', @@ -210,7 +209,7 @@ transformAssets: { const cdnBase = await fetchCdnBaseForRegion(region) return (asset) => { - if (asset.kind === 'modulepreload') { + if (asset.kind === 'script') { return { href: `${cdnBase}${asset.url}`, crossOrigin: 'anonymous', @@ -320,7 +319,7 @@ Warmup has no effect in development mode or when `cache: false`. ## Use Relative Vite Asset Paths for Client Navigation -`transformAssets` rewrites the URLs in the SSR HTML: modulepreload hints, stylesheet links, and the client entry module. This means the browser's initial page load can fetch those assets from the CDN. +`transformAssets` rewrites the URLs in the SSR HTML: script preload hints, stylesheet links, and the client entry script. This means the browser's initial page load can fetch those assets from the CDN. When users navigate client-side, TanStack Router lazy-loads route chunks using `import()` calls with paths baked in by the bundler. With Vite's default `base: '/'`, those paths are absolute, such as `/assets/about-abc123.js`, and resolve against the app server origin instead of the CDN. @@ -336,7 +335,7 @@ export default defineConfig({ }) ``` -With `base: ''`, the client entry module can be loaded from the CDN by `transformAssets`, and relative `import()` calls resolve against that same CDN origin. This keeps lazy-loaded route chunks on the CDN during client-side navigation. +With `base: ''`, the client entry script can be loaded from the CDN by `transformAssets`, and relative `import()` calls resolve against that same CDN origin. This keeps lazy-loaded route chunks on the CDN during client-side navigation. Using an empty string rather than `'./'` is important. Both produce relative client-side imports, but `base: ''` preserves the root-relative paths in the SSR manifest so `transformAssets` can prepend the CDN origin correctly. diff --git a/docs/start/framework/react/guide/early-hints.md b/docs/start/framework/react/guide/early-hints.md index 2c63bd5289..da633a84d7 100644 --- a/docs/start/framework/react/guide/early-hints.md +++ b/docs/start/framework/react/guide/early-hints.md @@ -165,6 +165,7 @@ Static Early Hints are collected from the final Start manifest resolved for the - CDN URL rewrites are reflected in Early Hints. - `crossOrigin` returned from `transformAssets` is reflected in Early Hints. +- JavaScript hints follow the client output format: `modulepreload` for module output, or `preload; as=script` for IIFE output. - Per-request transforms with `cache: false` are reflected in Early Hints for that request. - Inlined CSS assets are skipped when Start's [CSS inlining](./css-styling#inline-route-css-in-production) build option inlines them into the HTML. diff --git a/e2e/react-router/basic-file-based/src/routeTree.gen.ts b/e2e/react-router/basic-file-based/src/routeTree.gen.ts index daf304c064..7ee0051549 100644 --- a/e2e/react-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/react-router/basic-file-based/src/routeTree.gen.ts @@ -780,9 +780,9 @@ const NonNestedDeepBazBarFooQuxRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/fullpath-test': typeof FullpathTestLayoutRouteRouteWithChildren + '/fullpath-test': typeof FullpathTestRouteRouteWithChildren '/non-nested': typeof NonNestedRouteRouteWithChildren - '/pathless-layout': typeof PathlessLayoutLayoutRouteRouteWithChildren + '/pathless-layout': typeof PathlessLayoutRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren '/anchor': typeof AnchorRoute diff --git a/e2e/react-start/basic/package.json b/e2e/react-start/basic/package.json index eaf648c6ab..4e0fd86ab8 100644 --- a/e2e/react-start/basic/package.json +++ b/e2e/react-start/basic/package.json @@ -71,6 +71,14 @@ "toolchain": "rsbuild", "mode": "ssr" }, + { + "toolchain": "rsbuild", + "mode": "ssr", + "name": "iife", + "env": { + "TSS_RSB_CLIENT_OUTPUT": "iife" + } + }, { "toolchain": "rsbuild", "mode": "spa" diff --git a/e2e/react-start/basic/playwright.config.ts b/e2e/react-start/basic/playwright.config.ts index 5e46a2b1c7..4d92fc032d 100644 --- a/e2e/react-start/basic/playwright.config.ts +++ b/e2e/react-start/basic/playwright.config.ts @@ -57,6 +57,9 @@ export default defineConfig({ PORT: String(PORT), E2E_DIST_DIR: distDir, E2E_PORT_KEY: e2ePortKey, + ...(process.env.TSS_RSB_CLIENT_OUTPUT + ? { TSS_RSB_CLIENT_OUTPUT: process.env.TSS_RSB_CLIENT_OUTPUT } + : {}), }, }, diff --git a/e2e/react-start/basic/start-mode-config.ts b/e2e/react-start/basic/start-mode-config.ts index 3cb6db2658..efc5862eea 100644 --- a/e2e/react-start/basic/start-mode-config.ts +++ b/e2e/react-start/basic/start-mode-config.ts @@ -1,6 +1,18 @@ import { isPrerender } from './tests/utils/isPrerender' import { isSpaMode } from './tests/utils/isSpaMode' +const rsbuildClientOutput: 'module' | 'iife' | undefined = (() => { + const output = process.env.TSS_RSB_CLIENT_OUTPUT + + if (output === undefined) return undefined + if (output === 'module') return 'module' + if (output === 'iife') return 'iife' + + throw new Error( + `Invalid TSS_RSB_CLIENT_OUTPUT: ${output}. Expected "module" or "iife".`, + ) +})() + export function getStartModeConfig() { return { spa: isSpaMode @@ -28,5 +40,12 @@ export function getStartModeConfig() { maxRedirects: 100, } : undefined, + rsbuild: rsbuildClientOutput + ? { + client: { + output: rsbuildClientOutput, + }, + } + : undefined, } } diff --git a/e2e/react-start/basic/tests/client-output.spec.ts b/e2e/react-start/basic/tests/client-output.spec.ts new file mode 100644 index 0000000000..41cf21870d --- /dev/null +++ b/e2e/react-start/basic/tests/client-output.spec.ts @@ -0,0 +1,32 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test.skip( + process.env.TSS_RSB_CLIENT_OUTPUT !== 'iife', + 'IIFE client output assertions only run in rsbuild/ssr/iife mode', +) + +test('SSR HTML emits IIFE client scripts and classic script preloads', async ({ + page, +}) => { + const response = await page.goto('/posts') + const html = await response!.text() + + expect(html).not.toContain('rel="modulepreload"') + expect(html).toMatch(/]+rel="preload"[^>]+as="script"/) + + const clientEntry = html.match( + /]+src="([^"]*\/static\/js\/index[^"]*)"[^>]*>/, + ) + expect(clientEntry).toBeTruthy() + expect(clientEntry![0]).toContain('async') + expect(clientEntry![0]).not.toContain('type="module"') + + await expect( + page.getByRole('link', { name: 'sunt aut facere repe' }), + ).toBeVisible() + + await page.getByRole('link', { name: 'sunt aut facere repe' }).click() + + await expect(page.getByRole('heading')).toContainText('sunt aut facere') +}) diff --git a/e2e/react-start/transform-asset-urls/src/routes/__root.tsx b/e2e/react-start/transform-asset-urls/src/routes/__root.tsx index ff4961a1a4..334bc93088 100644 --- a/e2e/react-start/transform-asset-urls/src/routes/__root.tsx +++ b/e2e/react-start/transform-asset-urls/src/routes/__root.tsx @@ -29,7 +29,7 @@ function RootComponent() { diff --git a/e2e/react-start/transform-asset-urls/src/server.ts b/e2e/react-start/transform-asset-urls/src/server.ts index 5f57a1ef4b..241fc2fe51 100644 --- a/e2e/react-start/transform-asset-urls/src/server.ts +++ b/e2e/react-start/transform-asset-urls/src/server.ts @@ -27,7 +27,7 @@ const createTransformAssetsFn = ({ kind, url }) => { const href = `${cdn}${url}` - if (kind === 'modulepreload') { + if (kind === 'script') { return { href, crossOrigin: 'anonymous', diff --git a/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts b/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts index b006412243..e81fe7ca8c 100644 --- a/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts +++ b/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts @@ -39,17 +39,17 @@ test.describe('transformAssets with CDN prefix', () => { } }) - test('SSR HTML contains CDN-prefixed modulepreload links', async ({ + test('SSR HTML contains CDN-prefixed script preload links', async ({ page, }) => { const html = await getSSRHtml(page) - // All modulepreload links should point to the CDN origin - const modulepreloads = html.match(/rel="modulepreload"[^>]*href="([^"]+)"/g) - expect(modulepreloads).toBeTruthy() - expect(modulepreloads!.length).toBeGreaterThan(0) + // All script preload links should point to the CDN origin + const scriptPreloads = html.match(/rel="modulepreload"[^>]*href="([^"]+)"/g) + expect(scriptPreloads).toBeTruthy() + expect(scriptPreloads!.length).toBeGreaterThan(0) - for (const match of modulepreloads!) { + for (const match of scriptPreloads!) { const href = match.match(/href="([^"]+)"/)?.[1] expect(href).toBeTruthy() expect(href).toMatch(/^http:\/\/localhost:\d+\//) @@ -61,10 +61,10 @@ test.describe('transformAssets with CDN prefix', () => { }) => { const html = await getSSRHtml(page) - const modulepreloadLink = html.match( + const scriptPreloadLink = html.match( /]*rel="modulepreload"[^>]*crossorigin="anonymous"[^>]*>/, ) - expect(modulepreloadLink).toBeTruthy() + expect(scriptPreloadLink).toBeTruthy() const stylesheetLink = html.match( /]*rel="stylesheet"[^>]*crossorigin="use-credentials"[^>]*>/, @@ -134,10 +134,8 @@ test.describe('transformAssets with CDN prefix', () => { }) => { const html = await getSSRHtml(page) - // The client entry script should contain an import() with CDN-prefixed URL - // JSON.stringify produces double quotes; bundler optimisation may use single quotes const clientEntryMatch = html.match( - /import\(["'](http:\/\/localhost:\d+\/[^"']+)["']\)/, + /]+src="(http:\/\/localhost:\d+\/[^"]*\/index[^"]*)"[^>]*>/, ) expect(clientEntryMatch).toBeTruthy() expect(clientEntryMatch![1]).toMatch(/^http:\/\/localhost:\d+\//) diff --git a/packages/react-router/src/Asset.tsx b/packages/react-router/src/Asset.tsx index e3fa1c06de..d37f4aa993 100644 --- a/packages/react-router/src/Asset.tsx +++ b/packages/react-router/src/Asset.tsx @@ -14,10 +14,15 @@ interface ScriptAttrs { suppressHydrationWarning?: boolean } +const noopScriptHandler = () => {} + export function Asset( - asset: RouterManagedTag & { nonce?: string }, + asset: RouterManagedTag & { + nonce?: string + preventScriptHoist?: boolean + }, ): React.ReactElement | null { - const { attrs, children, nonce } = asset + const { attrs, children, nonce, preventScriptHoist } = asset switch (asset.tag) { case 'title': @@ -61,7 +66,11 @@ export function Asset( /> ) case 'script': - return + return ( + + ) default: return null } @@ -106,9 +115,11 @@ function InlineCssStyle({ function Script({ attrs, children, + preventScriptHoist, }: { attrs?: ScriptAttrs children?: string + preventScriptHoist?: boolean }) { const router = useRouter() const hydrated = useHydrated() @@ -228,7 +239,20 @@ function Script({ // --- Server rendering --- if (isServer ?? router.isServer) { if (attrs?.src) { - return ` router.serverSsr!.injectHtml(html) }, - dehydrate: async (opts?: { requestAssets?: Array }) => { + dehydrate: async (opts?: { requestAssets?: ManifestRouteAssets }) => { if (_dehydrated) { if (process.env.NODE_ENV !== 'production') { throw new Error('Invariant failed: router is already dehydrated!') @@ -357,8 +403,8 @@ export function attachRouterServerSsrUtils({ const matches = matchesToDehydrate.map(dehydrateMatch) let manifestToDehydrate: Manifest | undefined = undefined - // For currently matched routes, send full manifest (preloads + assets). - // For unmatched routes, include assets only when includeUnmatchedRouteAssets + // For currently matched routes, send full manifest data. + // For unmatched routes, include renderable assets only when includeUnmatchedRouteAssets // is true; otherwise omit them entirely. Preloads for unmatched routes are // still excluded because they are handled via dynamic imports. if (manifest) { @@ -382,11 +428,13 @@ export function attachRouterServerSsrUtils({ nextFilteredRoutes[routeId] = routeManifest } else if ( includeUnmatchedRouteAssets && - routeManifest.assets && - routeManifest.assets.length > 0 + hasRouteAssets(routeManifest) ) { nextFilteredRoutes[routeId] = { - assets: routeManifest.assets, + ...(routeManifest.scripts + ? { scripts: routeManifest.scripts } + : {}), + ...(routeManifest.css ? { css: routeManifest.css } : {}), } } } @@ -394,7 +442,6 @@ export function attachRouterServerSsrUtils({ filteredRoutes = stripInlinedStylesheetAssets( manifest, nextFilteredRoutes, - matchesToDehydrate, ) if (isProd) { @@ -402,20 +449,27 @@ export function attachRouterServerSsrUtils({ } } + const inlineCssAsset = getInlineCssAssetForMatches( + manifest, + matchesToDehydrate, + ) + manifestToDehydrate = { ...(manifest.scriptFormat ? { scriptFormat: manifest.scriptFormat } : {}), + ...(inlineCssAsset + ? { inlineStyle: createInlineCssPlaceholderAsset() } + : {}), routes: { ...filteredRoutes }, } // Merge request-scoped assets into root route (without mutating cached manifest) - if (opts?.requestAssets?.length) { + const requestAssets = opts?.requestAssets + if (hasRequestAssets(requestAssets)) { const existingRoot = manifestToDehydrate.routes[rootRouteId] - manifestToDehydrate.routes[rootRouteId] = { - ...existingRoot, - assets: [...opts.requestAssets, ...(existingRoot?.assets ?? [])], - } + manifestToDehydrate.routes[rootRouteId] = + mergeRequestAssetsIntoRootRoute(existingRoot, requestAssets) } } const dehydratedRouter: DehydratedRouter = { diff --git a/packages/router-core/tests/manifest.test.ts b/packages/router-core/tests/manifest.test.ts new file mode 100644 index 0000000000..f9d5d961df --- /dev/null +++ b/packages/router-core/tests/manifest.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { appendUniqueUserTags } from '../src/manifest' +import type { RouterManagedTag } from '../src/manifest' + +describe('appendUniqueUserTags', () => { + it('does not modify the target for an empty category', () => { + const existing: RouterManagedTag = { + tag: 'meta', + attrs: { + name: 'description', + content: 'existing', + }, + } + const target = [existing] + + appendUniqueUserTags(target, []) + + expect(target).toEqual([existing]) + }) + + it('appends a single tag unchanged', () => { + const tag: RouterManagedTag = { + tag: 'title', + children: 'Home', + } + const target: Array = [] + + appendUniqueUserTags(target, [tag]) + + expect(target).toEqual([tag]) + expect(target[0]).toBe(tag) + }) + + it('dedupes equivalent tags within one appended category', () => { + const firstTag: RouterManagedTag = { + tag: 'link', + attrs: { + href: '/style.css', + rel: 'stylesheet', + }, + } + const duplicateTag: RouterManagedTag = { + tag: 'link', + attrs: { + href: '/style.css', + rel: 'stylesheet', + }, + } + const nextTag: RouterManagedTag = { + tag: 'link', + attrs: { + href: '/next.css', + rel: 'stylesheet', + }, + } + const target: Array = [] + + appendUniqueUserTags(target, [firstTag, duplicateTag, nextTag]) + + expect(target).toEqual([firstTag, nextTag]) + expect(target[0]).toBe(firstTag) + }) + + it('keeps tags that differ by attributes within one category', () => { + const target: Array = [] + + appendUniqueUserTags(target, [ + { + tag: 'meta', + attrs: { + name: 'description', + content: 'first', + }, + }, + { + tag: 'meta', + attrs: { + name: 'description', + content: 'second', + }, + }, + ]) + + expect(target).toEqual([ + { + tag: 'meta', + attrs: { + name: 'description', + content: 'first', + }, + }, + { + tag: 'meta', + attrs: { + name: 'description', + content: 'second', + }, + }, + ]) + }) + + it('dedupes only within the appended tag category', () => { + const tag: RouterManagedTag = { + tag: 'link', + attrs: { + href: '/style.css', + rel: 'stylesheet', + }, + } + const target: Array = [] + + appendUniqueUserTags(target, [tag, tag]) + appendUniqueUserTags(target, [tag]) + + expect(target).toEqual([tag, tag]) + }) +}) diff --git a/packages/router-core/tests/ssr-server-manifest.test.ts b/packages/router-core/tests/ssr-server-manifest.test.ts index f83d15d33f..fbe938c16d 100644 --- a/packages/router-core/tests/ssr-server-manifest.test.ts +++ b/packages/router-core/tests/ssr-server-manifest.test.ts @@ -5,7 +5,11 @@ import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { GLOBAL_TSR } from '../src/ssr/constants' import { createTestRouter } from './routerTestUtils' import { describe, expect, test } from 'vitest' -import type { Manifest } from '../src/manifest' +import type { + ManifestCssLink, + ManifestRouteAssets, + ServerManifest, +} from '../src/manifest' import type { DehydratedRouter } from '../src/ssr/types' function buildRouter() { @@ -30,35 +34,28 @@ function buildRouter() { }) } -function buildManifest(): Manifest { - const sharedAsset = { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', - }, - } +function buildManifest(): ServerManifest { + const sharedAsset: ManifestCssLink = '/assets/shared.css' return { routes: { __root__: { - assets: [sharedAsset], + css: [sharedAsset], preloads: ['/assets/root.js'], }, '/': { - assets: [sharedAsset], + css: [sharedAsset], preloads: ['/assets/index.js'], }, '/posts': { - assets: [sharedAsset], + css: [sharedAsset], preloads: ['/assets/posts.js'], }, }, } } -function buildInlineManifest(): Manifest { +function buildInlineManifest(): ServerManifest { const manifest = buildManifest() return { ...manifest, @@ -114,16 +111,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { const manifest = await dehydrateManifest() expect(manifest.routes['/posts']).toEqual({ - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', - }, - }, - ], + css: ['/assets/shared.css'], }) }) @@ -138,7 +126,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { test('preserves script format when dehydrating the manifest', async () => { const router = buildRouter() - const manifest: Manifest = { + const manifest: ServerManifest = { ...buildManifest(), scriptFormat: 'iife', } @@ -153,11 +141,176 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { const script = router.serverSsr!.takeBufferedScripts() expect(script?.children).toBeTruthy() - const dehydratedManifest = parseSerializedRouter(script!.children!).manifest! + const dehydratedManifest = parseSerializedRouter( + script!.children!, + ).manifest! expect(dehydratedManifest.scriptFormat).toBe('iife') }) + test('maps request-scoped preload links into SSR manifest data', async () => { + const router = buildRouter() + const manifest = buildManifest() + const requestAssets: ManifestRouteAssets = { + preloads: [ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + ], + scripts: [ + { + attrs: { + src: '/assets/request-script.js', + type: 'module', + }, + children: 'console.log("request")', + }, + ], + css: [ + { href: '/assets/rsc-client.css', crossOrigin: 'use-credentials' }, + ], + } + + attachRouterServerSsrUtils({ + router, + manifest, + getRequestAssets: () => requestAssets, + }) + + await router.load() + + expect(router.ssr!.manifest?.routes.__root__).toMatchObject({ + preloads: [ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + '/assets/root.js', + ], + scripts: [ + { + attrs: { + src: '/assets/request-script.js', + type: 'module', + }, + children: 'console.log("request")', + }, + ], + css: [ + { href: '/assets/rsc-client.css', crossOrigin: 'use-credentials' }, + '/assets/shared.css', + ], + }) + }) + + test('maps preloads-only request assets into SSR manifest data', async () => { + const router = buildRouter() + const manifest = buildManifest() + const requestAssets: ManifestRouteAssets = { + preloads: [ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + ], + } + + attachRouterServerSsrUtils({ + router, + manifest, + getRequestAssets: () => requestAssets, + }) + + await router.load() + + expect(router.ssr!.manifest?.routes.__root__?.preloads).toEqual([ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + '/assets/root.js', + ]) + }) + + test('dehydrates request-scoped preload links into manifest data', async () => { + const router = buildRouter() + const manifest = buildManifest() + + attachRouterServerSsrUtils({ + router, + manifest, + }) + + await router.load() + await router.serverSsr!.dehydrate({ + requestAssets: { + preloads: [ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + ], + scripts: [ + { + attrs: { + src: '/assets/request-script.js', + type: 'module', + }, + children: 'console.log("request")', + }, + ], + css: [ + { + href: '/assets/rsc-client.css', + crossOrigin: 'use-credentials', + }, + ], + }, + }) + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + const dehydratedManifest = parseSerializedRouter(script!.children!).manifest! + + expect(dehydratedManifest.routes.__root__).toMatchObject({ + preloads: [ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + '/assets/root.js', + ], + scripts: [ + { + attrs: { + src: '/assets/request-script.js', + type: 'module', + }, + children: 'console.log("request")', + }, + ], + css: [ + { href: '/assets/rsc-client.css', crossOrigin: 'use-credentials' }, + '/assets/shared.css', + ], + }) + expect(Array.isArray(dehydratedManifest.routes.__root__?.css)).toBe(true) + expect((dehydratedManifest.routes.__root__?.css as any).links).toBe( + undefined, + ) + }) + + test('dehydrates preloads-only request assets into manifest data', async () => { + const router = buildRouter() + const manifest = buildManifest() + + attachRouterServerSsrUtils({ + router, + manifest, + }) + + await router.load() + await router.serverSsr!.dehydrate({ + requestAssets: { + preloads: [ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + ], + }, + }) + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + const dehydratedManifest = parseSerializedRouter(script!.children!).manifest! + + expect(dehydratedManifest.routes.__root__?.preloads).toEqual([ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + '/assets/root.js', + ]) + }) + test('inlines stylesheet assets for SSR and strips stylesheet links from dehydration', async () => { const router = buildRouter() const manifest = buildInlineManifest() @@ -170,15 +323,10 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { await router.load() - const ssrRootAssets = router.ssr!.manifest?.routes.__root__?.assets ?? [] - expect( - ssrRootAssets.find( - (asset) => - asset.tag === 'style' && - asset.inlineCss && - asset.children === '.shared{color:red}', - ), - ).toBeTruthy() + const ssrInlineCss = router.ssr!.manifest?.inlineStyle + expect(ssrInlineCss).toMatchObject({ + children: '.shared{color:red}', + }) await router.serverSsr!.dehydrate() @@ -186,24 +334,22 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { expect(script?.children).toBeTruthy() const dehydratedRouter = parseSerializedRouter(script!.children!) const dehydratedManifest = dehydratedRouter.manifest! - const rootAssets = dehydratedManifest.routes.__root__?.assets ?? [] - const allAssets = Object.values(dehydratedManifest.routes).flatMap( - (route) => route.assets ?? [], + const rootInlineCss = dehydratedManifest.inlineStyle + const allLinks = Object.values(dehydratedManifest.routes).flatMap( + (route) => route.css ?? [], ) - expect(rootAssets).toEqual([ - { - tag: 'style', - attrs: { - suppressHydrationWarning: true, - }, - inlineCss: true, + expect(rootInlineCss).toEqual({ + attrs: { + suppressHydrationWarning: true, }, - ]) + }) + expect('inlineCss' in dehydratedManifest).toBe(false) expect( - allAssets.some( - (asset) => - asset.tag === 'link' && asset.attrs?.href === '/assets/shared.css', + allLinks.some((asset) => + typeof asset === 'string' + ? asset === '/assets/shared.css' + : asset.href === '/assets/shared.css', ), ).toBe(false) expect(dehydratedManifest.routes['/']?.preloads).toEqual([ diff --git a/packages/solid-router/src/HeadContent.dev.tsx b/packages/solid-router/src/HeadContent.dev.tsx index 83c241d3fb..adf46ab800 100644 --- a/packages/solid-router/src/HeadContent.dev.tsx +++ b/packages/solid-router/src/HeadContent.dev.tsx @@ -1,12 +1,11 @@ import { MetaProvider } from '@solidjs/meta' import { For, createEffect, createMemo } from 'solid-js' +import { DEV_STYLES_ATTR } from '@tanstack/router-core' import { Asset } from './Asset' import { useHydrated } from './ClientOnly' import { useTags } from './headContentUtils' import type { HeadContentProps } from './HeadContent' -const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles' - /** * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route. * When using full document hydration (hydrating from ``), this component should be rendered in the `` @@ -33,7 +32,9 @@ export function HeadContent(props: HeadContentProps) { // Filter out dev styles after hydration const filteredTags = createMemo(() => { if (hydrated()) { - return tags().filter((tag) => !tag.attrs?.[DEV_STYLES_ATTR]) + return tags().filter( + (tag) => tag.tag !== 'link' || tag.attrs?.[DEV_STYLES_ATTR] !== true, + ) } return tags() }) diff --git a/packages/solid-router/src/Scripts.tsx b/packages/solid-router/src/Scripts.tsx index 48f507ad77..da3ceef65d 100644 --- a/packages/solid-router/src/Scripts.tsx +++ b/packages/solid-router/src/Scripts.tsx @@ -16,23 +16,21 @@ export const Scripts = () => { return [] } - matches - .map((match) => router.looseRoutesById[match.routeId]!) - .forEach((route) => { - const routeManifest = manifest.routes[route.id] + for (const match of matches) { + const scripts = manifest.routes[match.routeId]?.scripts - routeManifest?.assets - ?.filter((d) => d.tag === 'script') - .forEach((asset) => { - const scriptAsset = { - tag: 'script', - attrs: { ...asset.attrs, nonce }, - children: asset.children, - } satisfies RouterManagedTag + if (!scripts) { + continue + } - assetScripts.push(scriptAsset) - }) - }) + for (const asset of scripts) { + assetScripts.push({ + tag: 'script', + attrs: { ...asset.attrs, nonce }, + children: asset.children, + }) + } + } return assetScripts } @@ -67,10 +65,7 @@ function renderScripts( scripts: Array, assetScripts: Array, ) { - const allScripts = [ - ...scripts, - ...assetScripts, - ] as Array + const allScripts = [...scripts, ...assetScripts] as Array if ((isServer ?? router.isServer) && router.serverSsr) { const serverBufferedScript = router.serverSsr.takeBufferedScripts() @@ -81,7 +76,7 @@ function renderScripts( return ( <> - {allScripts.map((asset, i) => ( + {allScripts.map((asset) => ( ))} diff --git a/packages/solid-router/src/headContentUtils.tsx b/packages/solid-router/src/headContentUtils.tsx index 20954c39d4..b4ca3e8a4e 100644 --- a/packages/solid-router/src/headContentUtils.tsx +++ b/packages/solid-router/src/headContentUtils.tsx @@ -1,9 +1,10 @@ import * as Solid from 'solid-js' import { + appendUniqueUserTags, escapeHtml, getAssetCrossOrigin, getScriptPreloadAttrs, - isInlinableStylesheet, + resolveManifestCssLink, } from '@tanstack/router-core' import { useRouter } from './useRouter' import type { @@ -21,8 +22,8 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const activeMatches = Solid.createMemo(() => router.stores.matches.get()) const routeMeta = Solid.createMemo(() => activeMatches() - .map((match) => match.meta!) - .filter(Boolean), + .map((match) => match.meta) + .filter((meta) => meta !== undefined), ) const meta: Solid.Accessor> = Solid.createMemo(() => { @@ -100,9 +101,8 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const links = Solid.createMemo(() => { const matches = activeMatches() const constructed = matches - .map((match) => match.links!) - .filter(Boolean) - .flat(1) + .flatMap((match) => match.links ?? []) + .filter((link) => link !== undefined) .map((link) => ({ tag: 'link', attrs: { @@ -111,124 +111,111 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { }, })) satisfies Array - const manifest = router.ssr?.manifest + return constructed + }) - const assets = matches - .map((match) => manifest?.routes[match.routeId]?.assets ?? []) - .filter(Boolean) - .flat(1) - .flatMap((asset): Array => { - if (asset.tag === 'link') { - if (isInlinableStylesheet(manifest, asset)) { - return [] - } + const manifestCssTags = Solid.createMemo(() => { + const manifest = router.ssr?.manifest + const tags: Array = [] - return [ - { - tag: 'link', - attrs: { - ...asset.attrs, - crossOrigin: - getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? - asset.attrs?.crossOrigin, - nonce, - }, - }, - ] - } + if (!manifest) { + return tags + } - if (asset.tag === 'style') { - return [ - { - tag: 'style', - attrs: { - ...asset.attrs, - nonce, - }, - children: asset.children, - ...(asset.inlineCss ? { inlineCss: true as const } : {}), - }, - ] - } + for (const match of activeMatches()) { + manifest.routes[match.routeId]?.css?.forEach((link) => { + const resolvedLink = resolveManifestCssLink(link) + tags.push({ + tag: 'link', + attrs: { + rel: 'stylesheet', + ...resolvedLink, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? + resolvedLink.crossOrigin, + nonce, + }, + }) + }) + } - return [] + if (manifest.inlineStyle) { + tags.push({ + tag: 'style', + attrs: { + ...manifest.inlineStyle.attrs, + nonce, + }, + children: manifest.inlineStyle.children, + inlineCss: true, }) + } - return [...constructed, ...assets] + return tags }) const preloadLinks = Solid.createMemo(() => { const matches = activeMatches() const preloadLinks: Array = [] - matches - .map((match) => router.looseRoutesById[match.routeId]!) - .forEach((route) => - router.ssr?.manifest?.routes[route.id]?.preloads - ?.filter(Boolean) - .forEach((preload) => { - preloadLinks.push({ - tag: 'link', - attrs: { - ...getScriptPreloadAttrs( - router.ssr?.manifest, - preload, - assetCrossOrigin, - ), - nonce, - }, - }) - }), - ) + matches.forEach((match) => + router.ssr?.manifest?.routes[match.routeId]?.preloads + ?.filter(Boolean) + .forEach((preload) => { + preloadLinks.push({ + tag: 'link', + attrs: { + ...getScriptPreloadAttrs( + router.ssr?.manifest, + preload, + assetCrossOrigin, + ), + nonce, + }, + }) + }), + ) return preloadLinks }) - const styles = Solid.createMemo(() => - ( - activeMatches() - .map((match) => match.styles!) - .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...style }) => ({ - tag: 'style', - attrs: { - ...style, - nonce, - }, - children, - })), - ) + const styles = Solid.createMemo(() => { + return activeMatches() + .flatMap((match) => match.styles ?? []) + .filter((style) => style !== undefined) + .map(({ children, ...style }) => ({ + tag: 'style', + attrs: { + ...style, + nonce, + }, + children: children as string | undefined, + })) satisfies Array + }) - const headScripts = Solid.createMemo(() => - ( - activeMatches() - .map((match) => match.headScripts!) - .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...script }) => ({ - tag: 'script', - attrs: { - ...script, - nonce, - }, - children, - })), - ) + const headScripts = Solid.createMemo(() => { + return activeMatches() + .flatMap((match) => match.headScripts ?? []) + .filter((script) => script !== undefined) + .map(({ children, ...script }) => ({ + tag: 'script', + attrs: { + ...script, + nonce, + }, + children: children as string | undefined, + })) satisfies Array + }) return Solid.createMemo((prev: Array | undefined) => { - const next = uniqBy( - [ - ...meta(), - ...preloadLinks(), - ...links(), - ...styles(), - ...headScripts(), - ] as Array, - (d) => { - return JSON.stringify(d) - }, - ) + const next: Array = [] + appendUniqueUserTags(next, meta()) + next.push(...preloadLinks()) + appendUniqueUserTags(next, links()) + next.push(...manifestCssTags()) + appendUniqueUserTags(next, styles()) + appendUniqueUserTags(next, headScripts()) + if (prev === undefined) { return next } @@ -261,15 +248,3 @@ function replaceEqualTags( return isEqual ? prev : result } - -export function uniqBy(arr: Array, fn: (item: T) => string) { - const seen = new Set() - return arr.filter((item) => { - const key = fn(item) - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) -} diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index 863a902bbc..b15faaef19 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -30,15 +30,7 @@ const createTestManifest = ( routes: { [routeId]: { preloads: ['/main.js'], - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/main.css', - }, - }, - ], + css: ['/main.css'], }, }, }) satisfies Manifest @@ -263,23 +255,13 @@ describe('ssr scripts', () => { routes: { [rootRoute.id]: { preloads: ['/root.js'], - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/main.css', - }, - }, - ], + css: ['/main.css'], }, [aRoute.id]: { preloads: ['/a.js'], - assets: [], }, [bRoute.id]: { preloads: ['/b.js', '/b-child.js'], - assets: [], }, }, }, @@ -318,7 +300,7 @@ describe('ssr scripts', () => { ).toHaveLength(1) }) - test('applies assetCrossOrigin to manifest assets and preloads', async () => { + test('applies assetCrossOrigin to manifest stylesheets and preloads', async () => { const history = createTestBrowserHistory() const rootRoute = createRootRoute({ @@ -375,6 +357,58 @@ describe('ssr scripts', () => { ).toBe('anonymous') }) + test('renders runtime manifest inlineStyle', async () => { + const history = createTestBrowserHistory() + + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: { + inlineStyle: { + attrs: { id: 'runtime-inline-style' }, + children: '.runtime{color:red}', + }, + routes: { + [rootRoute.id]: {}, + }, + }, + } + + await router.load() + + render(() => ) + + await waitFor(() => { + expect( + document.head.querySelector('style#runtime-inline-style'), + ).toBeTruthy() + }) + + expect( + document.head.querySelector('style#runtime-inline-style')?.textContent, + ).toBe('.runtime{color:red}') + }) + test('renders preload as script links for iife manifest preloads', async () => { const history = createTestBrowserHistory() diff --git a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts index a3252ac9ef..e93d9cca3a 100644 --- a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts +++ b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts @@ -4,6 +4,7 @@ import { joinURL } from 'ufo' import { getStylesheetHref, resolveManifestAssetLink, + resolveManifestCssLink, rootRouteId, } from '@tanstack/router-core' import { @@ -14,7 +15,8 @@ import { import { processInlineCssUrls } from './inlineCss' import type { ManifestAssetLink, - RouterManagedTag, + ManifestCssLink, + ManifestScript, ScriptFormat, } from '@tanstack/router-core' import type { InlineCssTemplate } from './inlineCss' @@ -25,12 +27,17 @@ const VISITING_CHUNK = 1 type RouteTreeRoute = { filePath?: string preloads?: Array - assets?: Array + scripts?: Array + css?: Array children?: Array } type RouteTreeRoutes = Record +type AdditionalRouteManifestEntry = + | ManifestCssLink + | ManifestScript + interface ScannedClientChunks { entryChunk: NormalizedClientChunk chunksByFileName: ReadonlyMap @@ -40,12 +47,13 @@ interface ScannedClientChunks { interface ManifestAssetResolvers { getAssetPath: (fileName: string) => string getChunkPreloads: (chunk: NormalizedClientChunk) => Array - getStylesheetAsset: (cssFile: string) => RouterManagedTag + getStylesheetLink: (cssFile: string) => ManifestCssLink } type DedupeRoute = { preloads?: Array - assets?: Array + scripts?: Array + css?: Array children?: Array } @@ -97,12 +105,10 @@ export function appendUniqueStrings( return result ?? target } -export function appendUniqueAssets( - target: Array | undefined, - source: Array, +function appendUniqueStylesheets( + target: Array | undefined, + source: Array, ) { - // Same semantics as appendUniqueStrings, but uniqueness is based on the - // serialized asset identity instead of object reference. if (source.length === 0) { return target } @@ -111,11 +117,11 @@ export function appendUniqueAssets( return source } - const seen = new Set(target.map(getAssetIdentity)) - let result: Array | undefined + const seen = new Set(target.map(getStylesheetIdentity)) + let result: Array | undefined - for (const asset of source) { - const identity = getAssetIdentity(asset) + for (const stylesheet of source) { + const identity = getStylesheetIdentity(stylesheet) if (seen.has(identity)) { continue } @@ -124,21 +130,25 @@ export function appendUniqueAssets( if (!result) { result = target.slice() } - result.push(asset) + result.push(stylesheet) } return result ?? target } -function getAssetIdentity(asset: RouterManagedTag) { +function getStylesheetIdentity(attrs: ManifestCssLink) { + const resolved = resolveManifestCssLink(attrs) + return `${resolved.href}\0${resolved.crossOrigin ?? ''}` +} + +function getScriptIdentity(script: ManifestScript) { return JSON.stringify({ - tag: asset.tag, - attrs: normalizeAssetAttrs(asset.attrs), - children: 'children' in asset ? (asset.children ?? null) : null, + attrs: normalizeAttrs(script.attrs), + children: script.children ?? null, }) } -function normalizeAssetAttrs(attrs: Record | undefined) { +function normalizeAttrs(attrs: Record | undefined) { if (!attrs) { return null } @@ -155,19 +165,57 @@ function normalizeAssetAttrs(attrs: Record | undefined) { function mergeRouteChunkData(options: { route: RouteTreeRoute chunk: NormalizedClientChunk - getChunkCssAssets: (chunk: NormalizedClientChunk) => Array + getChunkCssAssets: ( + chunk: NormalizedClientChunk, + ) => Array getChunkPreloads: (chunk: NormalizedClientChunk) => Array }) { - const chunkAssets = options.getChunkCssAssets(options.chunk) + const stylesheets = options.getChunkCssAssets(options.chunk) const chunkPreloads = options.getChunkPreloads(options.chunk) - options.route.assets = appendUniqueAssets(options.route.assets, chunkAssets) + appendRouteStylesheets(options.route, stylesheets) options.route.preloads = appendUniqueStrings( options.route.preloads, chunkPreloads, ) } +function appendRouteStylesheets( + route: RouteTreeRoute, + stylesheets: Array, +) { + if (stylesheets.length === 0) { + return + } + + route.css = appendUniqueStylesheets(route.css, stylesheets) +} + +function appendAdditionalRouteEntries( + route: RouteTreeRoute, + entries: ReadonlyArray, +) { + if (entries.length === 0) { + return + } + + const stylesheets: Array = [] + const scripts: Array = [] + + for (const entry of entries) { + if (typeof entry === 'string' || 'href' in entry) { + stylesheets.push(entry) + } else { + scripts.push(entry) + } + } + + appendRouteStylesheets(route, stylesheets) + if (scripts.length > 0) { + route.scripts = [...(route.scripts ?? []), ...scripts] + } +} + export function buildStartManifest(options: { clientBuild: NormalizedClientBuild routeTreeRoutes: RouteTreeRoutes @@ -175,7 +223,7 @@ export function buildStartManifest(options: { inlineCss?: InlineCssOptions scriptFormat?: ScriptFormat additionalRouteAssets?: Partial< - Record> + Record> > }): StartManifest { const scannedChunks = scanClientChunks(options.clientBuild) @@ -190,14 +238,19 @@ export function buildStartManifest(options: { additionalRouteAssets: options.additionalRouteAssets, }) - dedupeNestedRouteManifestEntries(rootRouteId, routes[rootRouteId]!, routes) + dedupeNestedRouteManifestEntries( + rootRouteId, + routes[rootRouteId]!, + routes, + ) - // Prune routes with no assets or preloads from the manifest + // Prune routes with no manifest data for (const routeId of Object.keys(routes)) { const route = routes[routeId]! - const hasAssets = route.assets && route.assets.length > 0 + const hasScripts = route.scripts && route.scripts.length > 0 + const hasCssLinks = route.css && route.css.length > 0 const hasPreloads = route.preloads && route.preloads.length > 0 - if (!hasAssets && !hasPreloads) { + if (!hasScripts && !hasCssLinks && !hasPreloads) { delete routes[routeId] } } @@ -264,7 +317,7 @@ export function createManifestAssetResolvers( basePath: string, ): ManifestAssetResolvers { const assetPathByFileName = new Map() - const stylesheetAssetByFileName = new Map() + const stylesheetLinkByFileName = new Map() const preloadsByChunk = new Map>() const getAssetPath = (fileName: string) => { @@ -278,24 +331,17 @@ export function createManifestAssetResolvers( return assetPath } - const getStylesheetAsset = (cssFile: string) => { - const cachedAsset = stylesheetAssetByFileName.get(cssFile) - if (cachedAsset) { - return cachedAsset + const getStylesheetLink = (cssFile: string) => { + const cachedLink = stylesheetLinkByFileName.get(cssFile) + if (cachedLink) { + return cachedLink } const href = getAssetPath(cssFile) - const asset = { - tag: 'link', - attrs: { - rel: 'stylesheet', - href, - type: 'text/css', - }, - } satisfies RouterManagedTag + const link = href satisfies ManifestCssLink - stylesheetAssetByFileName.set(cssFile, asset) - return asset + stylesheetLinkByFileName.set(cssFile, link) + return link } const getChunkPreloads = (chunk: NormalizedClientChunk) => { @@ -317,39 +363,39 @@ export function createManifestAssetResolvers( return { getAssetPath, getChunkPreloads, - getStylesheetAsset, + getStylesheetLink, } } export function createChunkCssAssetCollector(options: { chunksByFileName: ReadonlyMap - getStylesheetAsset: (cssFile: string) => RouterManagedTag + getStylesheetLink: (cssFile: string) => ManifestCssLink }) { - const assetsByChunk = new Map< + const linksByChunk = new Map< NormalizedClientChunk, - Array + Array >() const stateByChunk = new Map() const appendAsset = ( - assets: Array, - seenAssets: Set, - asset: RouterManagedTag, + links: Array, + seenLinks: Set, + link: ManifestCssLink, ) => { - if (seenAssets.has(asset)) { + if (seenLinks.has(link)) { return } - seenAssets.add(asset) - assets.push(asset) + seenLinks.add(link) + links.push(link) } const getChunkCssAssets = ( chunk: NormalizedClientChunk, - ): Array => { - const cachedAssets = assetsByChunk.get(chunk) - if (cachedAssets) { - return cachedAssets + ): Array => { + const cachedLinks = linksByChunk.get(chunk) + if (cachedLinks) { + return cachedLinks } if (stateByChunk.get(chunk) === VISITING_CHUNK) { @@ -357,8 +403,8 @@ export function createChunkCssAssetCollector(options: { } stateByChunk.set(chunk, VISITING_CHUNK) - const assets: Array = [] - const seenAssets = new Set() + const links: Array = [] + const seenLinks = new Set() for (let i = 0; i < chunk.imports.length; i++) { const importedChunk = options.chunksByFileName.get(chunk.imports[i]!) @@ -366,19 +412,19 @@ export function createChunkCssAssetCollector(options: { continue } - const importedAssets = getChunkCssAssets(importedChunk) - for (let j = 0; j < importedAssets.length; j++) { - appendAsset(assets, seenAssets, importedAssets[j]!) + const importedLinks = getChunkCssAssets(importedChunk) + for (let j = 0; j < importedLinks.length; j++) { + appendAsset(links, seenLinks, importedLinks[j]!) } } for (const cssFile of chunk.css) { - appendAsset(assets, seenAssets, options.getStylesheetAsset(cssFile)) + appendAsset(links, seenLinks, options.getStylesheetLink(cssFile)) } stateByChunk.delete(chunk) - assetsByChunk.set(chunk, assets) - return assets + linksByChunk.set(chunk, links) + return links } return { getChunkCssAssets } @@ -393,11 +439,8 @@ function buildInlineCssManifestData(options: { const stylesheetHrefs = new Set() for (const route of Object.values(options.routes)) { - for (const asset of route.assets ?? []) { - const href = getStylesheetHref(asset) - if (href) { - stylesheetHrefs.add(href) - } + for (const link of route.css ?? []) { + stylesheetHrefs.add(getStylesheetHref(link)) } } @@ -459,13 +502,13 @@ export function buildRouteManifestRoutes(options: { entryChunk: NormalizedClientChunk assetResolvers: ManifestAssetResolvers additionalRouteAssets?: Partial< - Record> + Record> > }) { const routes: Record = {} const getChunkCssAssets = createChunkCssAssetCollector({ chunksByFileName: options.chunksByFileName, - getStylesheetAsset: options.assetResolvers.getStylesheetAsset, + getStylesheetLink: options.assetResolvers.getStylesheetLink, }).getChunkCssAssets for (const [routeId, route] of Object.entries(options.routeTreeRoutes)) { @@ -545,7 +588,7 @@ export function buildRouteManifestRoutes(options: { } const route = (routes[routeId] = routes[routeId] || {}) - route.assets = appendUniqueAssets(route.assets, [...assets]) + appendAdditionalRouteEntries(route, assets) } } @@ -556,19 +599,20 @@ function mergeReachableHydrationChunkData(options: { route: RouteTreeRoute chunk: NormalizedClientChunk chunksByFileName: ReadonlyMap - getChunkCssAssets: (chunk: NormalizedClientChunk) => Array + getChunkCssAssets: ( + chunk: NormalizedClientChunk, + ) => Array }) { const visitedStaticChunks = new Set() const mergedHydrationChunks = new Set() const mergeHydrationChunk = (chunk: NormalizedClientChunk) => { - if (mergedHydrationChunks.has(chunk.fileName)) return + if (mergedHydrationChunks.has(chunk.fileName)) { + return + } mergedHydrationChunks.add(chunk.fileName) - options.route.assets = appendUniqueAssets( - options.route.assets, - options.getChunkCssAssets(chunk), - ) + appendRouteStylesheets(options.route, options.getChunkCssAssets(chunk)) for (const dynamicImport of chunk.dynamicImports) { const dynamicChunk = options.chunksByFileName.get(dynamicImport) @@ -579,7 +623,9 @@ function mergeReachableHydrationChunkData(options: { } const visitStaticChunk = (chunk: NormalizedClientChunk) => { - if (visitedStaticChunks.has(chunk.fileName)) return + if (visitedStaticChunks.has(chunk.fileName)) { + return + } visitedStaticChunks.add(chunk.fileName) for (const importedFileName of chunk.imports) { @@ -611,10 +657,12 @@ function dedupeNestedRouteManifestEntries( route: DedupeRoute, routesById: Record, seenPreloads = new Set(), - seenAssets = new Set(), + seenScripts = new Set(), + seenStylesheets = new Set(), ) { let routePreloads = route.preloads - let routeAssets = route.assets + let routeScripts = route.scripts + let routeStylesheets = route.css if (routePreloads && routePreloads.length > 0) { let dedupedPreloads: Array | undefined @@ -643,30 +691,65 @@ function dedupeNestedRouteManifestEntries( } } - if (routeAssets && routeAssets.length > 0) { - let dedupedAssets: Array | undefined + if (routeScripts && routeScripts.length > 0) { + let dedupedScripts: Array | undefined - for (let i = 0; i < routeAssets.length; i++) { - const asset = routeAssets[i]! - const identity = getAssetIdentity(asset) + for (let i = 0; i < routeScripts.length; i++) { + const script = routeScripts[i]! + const identity = getScriptIdentity(script) - if (seenAssets.has(identity)) { - if (dedupedAssets === undefined) { - dedupedAssets = routeAssets.slice(0, i) + if (seenScripts.has(identity)) { + if (dedupedScripts === undefined) { + dedupedScripts = routeScripts.slice(0, i) } continue } - seenAssets.add(identity) + seenScripts.add(identity) - if (dedupedAssets) { - dedupedAssets.push(asset) + if (dedupedScripts) { + dedupedScripts.push(script) } } - if (dedupedAssets) { - routeAssets = dedupedAssets - route.assets = dedupedAssets + if (dedupedScripts) { + routeScripts = dedupedScripts + if (dedupedScripts.length > 0) { + route.scripts = dedupedScripts + } else { + delete route.scripts + } + } + } + + if (routeStylesheets && routeStylesheets.length > 0) { + let dedupedStylesheets: Array | undefined + + for (let i = 0; i < routeStylesheets.length; i++) { + const stylesheet = routeStylesheets[i]! + const identity = getStylesheetIdentity(stylesheet) + + if (seenStylesheets.has(identity)) { + if (dedupedStylesheets === undefined) { + dedupedStylesheets = routeStylesheets.slice(0, i) + } + continue + } + + seenStylesheets.add(identity) + + if (dedupedStylesheets) { + dedupedStylesheets.push(stylesheet) + } + } + + if (dedupedStylesheets) { + routeStylesheets = dedupedStylesheets + if (dedupedStylesheets.length > 0) { + route.css = dedupedStylesheets + } else { + delete route.css + } } } @@ -685,7 +768,8 @@ function dedupeNestedRouteManifestEntries( childRoute, routesById, seenPreloads, - seenAssets, + seenScripts, + seenStylesheets, ) } } @@ -696,9 +780,15 @@ function dedupeNestedRouteManifestEntries( } } - if (routeAssets) { - for (let i = routeAssets.length - 1; i >= 0; i--) { - seenAssets.delete(getAssetIdentity(routeAssets[i]!)) + if (routeScripts) { + for (let i = routeScripts.length - 1; i >= 0; i--) { + seenScripts.delete(getScriptIdentity(routeScripts[i]!)) + } + } + + if (routeStylesheets) { + for (let i = routeStylesheets.length - 1; i >= 0; i--) { + seenStylesheets.delete(getStylesheetIdentity(routeStylesheets[i]!)) } } } diff --git a/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts index dc027cf32f..aa2ac40936 100644 --- a/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts @@ -99,10 +99,10 @@ function getViteAdditionalRouteAssets(options: { ) } - const { getStylesheetAsset } = createManifestAssetResolvers(options.basePath) + const { getStylesheetLink } = createManifestAssetResolvers(options.basePath) return { - [rootRouteId]: [getStylesheetAsset(options.cssCodeSplitDisabledFileName)], + [rootRouteId]: [getStylesheetLink(options.cssCodeSplitDisabledFileName)], } } diff --git a/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts b/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts index f950f9c1d1..f2a06de418 100644 --- a/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts +++ b/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from 'vitest' import { deserialize } from 'seroval' import { shouldRebaseInlineCssUrls } from '../../src/start-manifest-plugin/inlineCss' import { - appendUniqueAssets, appendUniqueStrings, buildStartManifest, createChunkCssAssetCollector, @@ -14,6 +13,7 @@ import { scanClientChunks, type StartManifest, } from '../../src/start-manifest-plugin/manifestBuilder' +import type { ManifestCssLink } from '@tanstack/router-core' import type { Rollup } from 'vite' function normalizeTestBuild(bundle: Rollup.OutputBundle) { @@ -73,6 +73,14 @@ function makeCssAsset(fileName: string, source: string): Rollup.OutputAsset { } as unknown as Rollup.OutputAsset } +function makeStylesheetLink(href: string): ManifestCssLink { + return href +} + +function getManifestCssHref(link: ManifestCssLink) { + return typeof link === 'string' ? link : link.href +} + describe('getRouteFilePathsFromModuleIds', () => { test('returns unique route file paths only for tsr split modules', () => { expect( @@ -131,112 +139,6 @@ describe('appendUniqueStrings', () => { }) }) -describe('appendUniqueAssets', () => { - test('dedupes by asset identity while preserving order', () => { - const baseAsset = { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - type: 'text/css', - }, - } as const - const duplicateAsset = { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - type: 'text/css', - }, - } as const - const newAsset = { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/b.css', - type: 'text/css', - }, - } as const - - expect(appendUniqueAssets([baseAsset], [duplicateAsset, newAsset])).toEqual( - [baseAsset, newAsset], - ) - }) - - test('returns original target array when nothing new is appended', () => { - const target = [ - { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - type: 'text/css', - }, - }, - ] - - expect( - appendUniqueAssets(target, [ - { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - type: 'text/css', - }, - }, - ]), - ).toBe(target) - }) - - test('keeps distinct link assets with different attributes', () => { - const stylesheetA = { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - media: 'screen', - type: 'text/css', - }, - } - const stylesheetB = { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - media: 'print', - type: 'text/css', - }, - } - - expect(appendUniqueAssets([stylesheetA], [stylesheetB])).toEqual([ - stylesheetA, - stylesheetB, - ]) - }) - - test('keeps distinct script assets with different attributes', () => { - const scriptA = { - tag: 'script' as const, - attrs: { - src: '/assets/app.js', - type: 'module', - async: true, - }, - } - const scriptB = { - tag: 'script' as const, - attrs: { - src: '/assets/app.js', - type: 'module', - defer: true, - }, - } - - expect(appendUniqueAssets([scriptA], [scriptB])).toEqual([scriptA, scriptB]) - }) -}) - describe('scanClientChunks', () => { test('collects entry chunk and route chunk mappings', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true }) @@ -283,7 +185,7 @@ describe('scanClientChunks', () => { }) describe('createManifestAssetResolvers + createChunkCssAssetCollector', () => { - test('reuses cached stylesheet assets', () => { + test('reuses cached stylesheet links', () => { const entryChunk = makeChunk({ fileName: 'entry.js', imports: ['shared.js'], @@ -298,37 +200,20 @@ describe('createManifestAssetResolvers + createChunkCssAssetCollector', () => { ['shared.js', normalizeTestChunk(sharedChunk)], ]) - const resolvers = createManifestAssetResolvers('/assets') - const cssAssetCollector = createChunkCssAssetCollector({ + const linkResolvers = createManifestAssetResolvers('/assets') + const cssLinkCollector = createChunkCssAssetCollector({ chunksByFileName, - getStylesheetAsset: resolvers.getStylesheetAsset, + getStylesheetLink: linkResolvers.getStylesheetLink, }) - const assets = cssAssetCollector.getChunkCssAssets( + const links = cssLinkCollector.getChunkCssAssets( chunksByFileName.get('entry.js')!, ) - expect(assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/entry.css', - type: 'text/css', - }, - }, - ]) + expect(links).toEqual(['/assets/shared.css', '/assets/entry.css']) - expect(resolvers.getStylesheetAsset('shared.css')).toBe( - resolvers.getStylesheetAsset('shared.css'), + expect(linkResolvers.getStylesheetLink('shared.css')).toBe( + linkResolvers.getStylesheetLink('shared.css'), ) }) }) @@ -363,15 +248,14 @@ describe('createChunkCssAssetCollector', () => { const { getChunkCssAssets } = createChunkCssAssetCollector({ chunksByFileName, - getStylesheetAsset: (cssFile) => ({ - tag: 'link', - attrs: { rel: 'stylesheet', href: `/${cssFile}`, type: 'text/css' }, + getStylesheetLink: (cssFile) => ({ + href: `/${cssFile}`, }), }) - const assets = getChunkCssAssets(chunksByFileName.get('a.js')!) + const links = getChunkCssAssets(chunksByFileName.get('a.js')!) - expect(assets.map((asset: any) => asset.attrs.href)).toEqual([ + expect(links.map(getManifestCssHref)).toEqual([ '/shared.css', '/b.css', '/c.css', @@ -397,14 +281,13 @@ describe('createChunkCssAssetCollector', () => { const { getChunkCssAssets } = createChunkCssAssetCollector({ chunksByFileName, - getStylesheetAsset: (cssFile) => ({ - tag: 'link', - attrs: { rel: 'stylesheet', href: `/${cssFile}`, type: 'text/css' }, + getStylesheetLink: (cssFile) => ({ + href: `/${cssFile}`, }), }) - const assets = getChunkCssAssets(chunksByFileName.get('a.js')!) - const hrefs = assets.map((a: any) => a.attrs.href) + const links = getChunkCssAssets(chunksByFileName.get('a.js')!) + const hrefs = links.map(getManifestCssHref) expect(hrefs).toContain('/a.css') expect(hrefs).toContain('/b.css') @@ -502,7 +385,7 @@ describe('buildStartManifest', () => { }) }) - test('throws when inline CSS content is missing for a stylesheet asset', () => { + test('throws when inline CSS content is missing for a stylesheet link', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, @@ -523,14 +406,14 @@ describe('buildStartManifest', () => { ).toThrow('could not find CSS content') }) - test('allows callers to attach additional route assets', () => { + test('allows callers to attach additional route stylesheet links', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, moduleIds: ['/src/entry.tsx'], }) - const assetResolvers = createManifestAssetResolvers('/assets') + const linkResolvers = createManifestAssetResolvers('/assets') const manifest = buildStartManifest({ clientBuild: normalizeViteClientBuild({ @@ -542,30 +425,85 @@ describe('buildStartManifest', () => { }, basePath: '/assets', additionalRouteAssets: { - __root__: [assetResolvers.getStylesheetAsset('style.css')], + __root__: [linkResolvers.getStylesheetLink('style.css')], }, }) - expect(manifest.routes.__root__!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/style.css', - type: 'text/css', - }, - }, + expect(manifest.routes.__root__!.css).toEqual([ + makeStylesheetLink('/assets/style.css'), ]) }) - test('rejects additional route assets for unknown route ids', () => { + test('dedupes duplicate additional route scripts on the same route', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, moduleIds: ['/src/entry.tsx'], }) + const script = { + attrs: { + src: '/assets/custom.js', + type: 'module', + }, + } as const + + const manifest = buildStartManifest({ + clientBuild: normalizeViteClientBuild({ + 'entry.js': entryChunk, + }), + routeTreeRoutes: { + __root__: { children: ['/about'] } as any, + '/about': { filePath: '/routes/about.tsx' }, + }, + basePath: '/assets', + additionalRouteAssets: { + __root__: [script, script], + }, + }) - const assetResolvers = createManifestAssetResolvers('/assets') + expect(manifest.routes.__root__!.scripts).toEqual([script]) + }) + + test('dedupes additional route scripts already owned by ancestor routes', () => { + const entryChunk = makeChunk({ + fileName: 'entry.js', + isEntry: true, + moduleIds: ['/src/entry.tsx'], + }) + const script = { + attrs: { + src: '/assets/custom.js', + type: 'module', + }, + } as const + + const manifest = buildStartManifest({ + clientBuild: normalizeViteClientBuild({ + 'entry.js': entryChunk, + }), + routeTreeRoutes: { + __root__: { children: ['/about'] } as any, + '/about': { filePath: '/routes/about.tsx' }, + }, + basePath: '/assets', + additionalRouteAssets: { + __root__: [script], + '/about': [script], + }, + }) + + expect(manifest.routes.__root__!.scripts).toEqual([script]) + expect(manifest.routes['/about']?.scripts).toBeUndefined() + }) + + test('rejects additional route entries for unknown route ids', () => { + const entryChunk = makeChunk({ + fileName: 'entry.js', + isEntry: true, + moduleIds: ['/src/entry.tsx'], + }) + + const linkResolvers = createManifestAssetResolvers('/assets') expect(() => buildStartManifest({ @@ -578,7 +516,7 @@ describe('buildStartManifest', () => { }, basePath: '/assets', additionalRouteAssets: { - '/missing': [assetResolvers.getStylesheetAsset('style.css')], + '/missing': [linkResolvers.getStylesheetLink('style.css')], }, }), ).toThrow( @@ -586,7 +524,7 @@ describe('buildStartManifest', () => { ) }) - test('dedupes route css gathered through overlapping chunk imports', () => { + test('dedupes route CSS gathered through overlapping chunk imports', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, @@ -626,31 +564,10 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes['/about']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/branch-a.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/branch-b.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/about']!.css).toEqual([ + makeStylesheetLink('/assets/shared.css'), + makeStylesheetLink('/assets/branch-a.css'), + makeStylesheetLink('/assets/branch-b.css'), ]) }) @@ -683,15 +600,8 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes['/about']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/about-widget.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/about']!.css).toEqual([ + makeStylesheetLink('/assets/about-widget.css'), ]) expect(manifest.routes['/about']!.preloads).toEqual(['/assets/about.js']) }) @@ -732,23 +642,9 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes['/about']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/parent-widget.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/child-widget.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/about']!.css).toEqual([ + makeStylesheetLink('/assets/parent-widget.css'), + makeStylesheetLink('/assets/child-widget.css'), ]) expect(manifest.routes['/about']!.preloads).toEqual(['/assets/about.js']) }) @@ -794,16 +690,13 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - const expectedAsset = { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/shared-hydrate.css', - type: 'text/css', - }, - } - expect(manifest.routes['/about']!.assets).toEqual([expectedAsset]) - expect(manifest.routes['/posts']!.assets).toEqual([expectedAsset]) + const expectedLink = makeStylesheetLink('/assets/shared-hydrate.css') + expect(manifest.routes['/about']!.css).toEqual([ + expectedLink, + ]) + expect(manifest.routes['/posts']!.css).toEqual([ + expectedLink, + ]) expect(manifest.routes['/about']!.preloads).toEqual([ '/assets/about.js', '/assets/shared-widget.js', @@ -843,7 +736,7 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes.__root__!.assets).toBeUndefined() + expect(manifest.routes.__root__!.css).toBeUndefined() expect(manifest.routes.__root__!.preloads).toEqual([ '/assets/entry.js', '/assets/app-shell.js', @@ -894,15 +787,8 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes.__root__!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/root-widget.css', - type: 'text/css', - }, - }, + expect(manifest.routes.__root__!.css).toEqual([ + makeStylesheetLink('/assets/root-widget.css'), ]) expect(manifest.routes.__root__!.preloads).toEqual([ '/assets/root.js', @@ -943,13 +829,11 @@ describe('buildStartManifest', () => { }) expect( - manifest.routes['/field-detail-panel']!.assets!.map( - (asset: any) => asset.attrs.href, - ), + manifest.routes['/field-detail-panel']!.css!.map(getManifestCssHref), ).toEqual(['/assets/tabs.css', '/assets/field-detail-panel.css']) }) - test('dedupes route css already owned by ancestor routes', () => { + test('dedupes route CSS already owned by ancestor routes', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, @@ -980,46 +864,37 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes.__root__!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/main.css', - type: 'text/css', - }, - }, + expect(manifest.routes.__root__!.css).toEqual([ + makeStylesheetLink('/assets/main.css'), ]) - expect(manifest.routes['/about']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/about.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/about']!.css).toEqual([ + makeStylesheetLink('/assets/about.css'), ]) }) - test('serializeStartManifest preserves shared asset identity across routes', () => { - const sharedAsset = { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', - }, - } + test('serializeStartManifest preserves shared link identity across routes', () => { + const sharedLink = { + href: '/assets/shared.css', + crossOrigin: 'anonymous', + } satisfies ManifestCssLink const manifest: StartManifest = { routes: { __root__: { children: ['/a', '/b', '/c'], }, - '/a': { assets: [sharedAsset], preloads: ['/assets/a.js'] }, - '/b': { assets: [sharedAsset], preloads: ['/assets/b.js'] }, - '/c': { assets: [sharedAsset], preloads: ['/assets/c.js'] }, + '/a': { + css: [sharedLink], + preloads: ['/assets/a.js'], + }, + '/b': { + css: [sharedLink], + preloads: ['/assets/b.js'], + }, + '/c': { + css: [sharedLink], + preloads: ['/assets/c.js'], + }, }, clientEntry: '/assets/entry.js', } @@ -1028,16 +903,16 @@ describe('buildStartManifest', () => { serializeStartManifest(manifest), ) - const aAsset = evaluated.routes['/a']?.assets?.[0] - const bAsset = evaluated.routes['/b']?.assets?.[0] - const cAsset = evaluated.routes['/c']?.assets?.[0] + const aLink = evaluated.routes['/a']?.css?.[0] + const bLink = evaluated.routes['/b']?.css?.[0] + const cLink = evaluated.routes['/c']?.css?.[0] - expect(aAsset).toBeDefined() - expect(aAsset).toBe(bAsset) - expect(bAsset).toBe(cAsset) + expect(aLink).toBeDefined() + expect(aLink).toBe(bLink) + expect(bLink).toBe(cLink) }) - test('serializeStartManifest preserves non-asset fields unchanged', () => { + test('serializeStartManifest preserves non-link fields unchanged', () => { const manifest: StartManifest = { routes: { __root__: { @@ -1048,9 +923,8 @@ describe('buildStartManifest', () => { filePath: '/routes/posts.tsx', children: ['/posts/$postId'], preloads: ['/assets/posts.js'], - assets: [ + scripts: [ { - tag: 'script' as const, attrs: { src: '/assets/posts.js', type: 'module', @@ -1068,7 +942,7 @@ describe('buildStartManifest', () => { ).toEqual(manifest) }) - test('serializeStartManifest handles manifests without route assets', () => { + test('serializeStartManifest handles manifests without route links', () => { const manifest: StartManifest = { routes: { __root__: { @@ -1120,7 +994,7 @@ describe('buildStartManifest', () => { }) describe('route tree dedupe in buildStartManifest', () => { - test('dedupes assets and preloads only along the active ancestor path', () => { + test('dedupes stylesheet links and preloads only along the active ancestor path', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, @@ -1179,65 +1053,30 @@ describe('route tree dedupe in buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes.__root__!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/root.css', - type: 'text/css', - }, - }, + expect(manifest.routes.__root__!.css).toEqual([ + makeStylesheetLink('/assets/shared.css'), + makeStylesheetLink('/assets/root.css'), ]) expect(manifest.routes.__root__!.preloads).toEqual([ '/assets/entry.js', '/assets/root-shared.js', ]) - expect(manifest.routes['/parent']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/parent.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/parent']!.css).toEqual([ + makeStylesheetLink('/assets/parent.css'), ]) expect(manifest.routes['/parent']!.preloads).toEqual([ '/assets/parent.js', '/assets/parent-only.js', ]) - expect(manifest.routes['/child']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/child.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/child']!.css).toEqual([ + makeStylesheetLink('/assets/child.css'), ]) expect(manifest.routes['/child']!.preloads).toEqual([ '/assets/child.js', '/assets/child-only.js', ]) - expect(manifest.routes['/sibling']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/sibling.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/sibling']!.css).toEqual([ + makeStylesheetLink('/assets/sibling.css'), ]) expect(manifest.routes['/sibling']!.preloads).toEqual([ '/assets/sibling.js', @@ -1297,37 +1136,16 @@ describe('route tree dedupe in buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes['/a-child']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/deep.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/a-child']!.css).toEqual([ + makeStylesheetLink('/assets/deep.css'), ]) expect(manifest.routes['/a-child']!.preloads).toEqual([ '/assets/a-child.js', '/assets/deep.js', ]) - expect(manifest.routes['/b-child']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/deep.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/b-child.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/b-child']!.css).toEqual([ + makeStylesheetLink('/assets/deep.css'), + makeStylesheetLink('/assets/b-child.css'), ]) expect(manifest.routes['/b-child']!.preloads).toEqual([ '/assets/b-child.js', @@ -1406,65 +1224,30 @@ describe('route tree dedupe in buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes.__root__!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/shared-root.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/root.css', - type: 'text/css', - }, - }, + expect(manifest.routes.__root__!.css).toEqual([ + makeStylesheetLink('/assets/shared-root.css'), + makeStylesheetLink('/assets/root.css'), ]) expect(manifest.routes.__root__!.preloads).toEqual([ '/assets/entry.js', '/assets/shared-root.js', ]) - expect(manifest.routes['/level-one']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/level-one.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/level-one']!.css).toEqual([ + makeStylesheetLink('/assets/level-one.css'), ]) expect(manifest.routes['/level-one']!.preloads).toEqual([ '/assets/level-one.js', '/assets/level-one-only.js', ]) - expect(manifest.routes['/level-two']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/level-two.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/level-two']!.css).toEqual([ + makeStylesheetLink('/assets/level-two.css'), ]) expect(manifest.routes['/level-two']!.preloads).toEqual([ '/assets/level-two.js', '/assets/level-two-only.js', ]) - expect(manifest.routes['/level-three']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/level-three.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/level-three']!.css).toEqual([ + makeStylesheetLink('/assets/level-three.css'), ]) expect(manifest.routes['/level-three']!.preloads).toEqual([ '/assets/level-three.js', @@ -1556,7 +1339,7 @@ describe('multi-chunk routes must merge assets and preloads', () => { const postsRoute = manifest.routes['/posts']! // Both chunks' CSS should be present - const cssHrefs = postsRoute.assets!.map((a: any) => a.attrs.href) + const cssHrefs = postsRoute.css!.map(getManifestCssHref) expect(cssHrefs).toContain('/assets/component.css') expect(cssHrefs).toContain('/assets/loader.css') @@ -1603,7 +1386,7 @@ describe('multi-chunk routes must merge assets and preloads', () => { }) const postsRoute = manifest.routes['/posts']! - const cssHrefs = postsRoute.assets!.map((a: any) => a.attrs.href) + const cssHrefs = postsRoute.css!.map(getManifestCssHref) const preloadHrefs = postsRoute.preloads! expect( @@ -1616,7 +1399,7 @@ describe('multi-chunk routes must merge assets and preloads', () => { }) describe('buildStartManifest route pruning', () => { - test('routes with no assets or preloads are pruned from returned manifest', () => { + test('routes with no manifest data are pruned from returned manifest', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, @@ -1634,7 +1417,7 @@ describe('buildStartManifest route pruning', () => { basePath: '/assets', }) - // /about has no matching chunks, so no assets or preloads. + // /about has no matching chunks, so no manifest data. // It should be pruned from the manifest. expect(manifest.routes['/about']).toBeUndefined() }) diff --git a/packages/start-server-core/src/early-hints.ts b/packages/start-server-core/src/early-hints.ts index 1c547e68b8..4ac7a4630a 100644 --- a/packages/start-server-core/src/early-hints.ts +++ b/packages/start-server-core/src/early-hints.ts @@ -1,13 +1,14 @@ import { getScriptPreloadAttrs, getStylesheetHref, + resolveManifestCssLink, } from '@tanstack/router-core' import type { AnyRoute, AnyRouteMatch, AssetCrossOrigin, - Manifest, RouterManagedTag, + ServerManifest, } from '@tanstack/router-core' export type EarlyHint = { @@ -49,7 +50,7 @@ export type ResponseLinkHeaderOptions = { export interface EarlyHintsCollector { collectStatic: (opts: { - manifest: Manifest + manifest: ServerManifest matchedRoutes?: ReadonlyArray }) => void collectDynamic: (matches: ReadonlyArray) => void @@ -174,7 +175,7 @@ function linkAttrsToEarlyHint( } export function collectStaticHintsFromManifest( - manifest: Manifest, + manifest: ServerManifest, matchedRoutes: ReadonlyArray, ): Array { const hints: Array = [] @@ -194,27 +195,22 @@ export function collectStaticHintsFromManifest( hints.push(hint) } - for (const asset of routeManifest.assets ?? []) { - if (asset.tag !== 'link') continue - - const stylesheetHref = getStylesheetHref(asset) - if (stylesheetHref) { - if (manifest.inlineCss?.styles[stylesheetHref] !== undefined) continue - - const hint: EarlyHint = { - href: stylesheetHref, - rel: 'preload', - as: 'style', - } - addEarlyHintFetchAttrs(hint, asset.attrs) - hints.push(hint) + for (const link of routeManifest.css ?? []) { + const resolvedLink = resolveManifestCssLink(link) + const stylesheetHref = getStylesheetHref(link) + if (manifest.inlineCss?.styles[stylesheetHref] !== undefined) { continue } - const hint = linkAttrsToEarlyHint(asset.attrs) - if (hint) { - hints.push(hint) + const hint: EarlyHint = { + href: stylesheetHref, + rel: 'preload', + as: 'style', } + if (resolvedLink.crossOrigin !== undefined) { + hint.crossOrigin = resolvedLink.crossOrigin + } + hints.push(hint) } } diff --git a/packages/start-server-core/src/finalManifest.ts b/packages/start-server-core/src/finalManifest.ts index 09976ee631..7cb5d35739 100644 --- a/packages/start-server-core/src/finalManifest.ts +++ b/packages/start-server-core/src/finalManifest.ts @@ -7,7 +7,7 @@ import { getStaticHandlerInlineCssDefault, resolveInlineCssForRequest, } from './inlineCss' -import type { Manifest } from '@tanstack/router-core' +import type { ServerManifest } from '@tanstack/router-core' import type { HandlerInlineCssOption } from './inlineCss' import type { CreateTransformAssetsContext, @@ -45,7 +45,7 @@ export interface FinalManifestOptions { } type FinalManifestCacheKey = 'inline-css' | 'linked-css' -type FinalManifestCache = Map> +type FinalManifestCache = Map> export type GetBaseManifest = () => Promise export interface FinalManifestRequestOptions { @@ -66,9 +66,9 @@ interface FinalManifestTransformResolver { export interface FinalManifestResolver { warmup: (opts: { getBaseManifest: GetBaseManifest - }) => Promise | undefined - resolveCached: (opts: FinalManifestRequestOptions) => Promise - resolveUncached: (opts: FinalManifestRequestOptions) => Promise + }) => Promise | undefined + resolveCached: (opts: FinalManifestRequestOptions) => Promise + resolveUncached: (opts: FinalManifestRequestOptions) => Promise } export function createCachedBaseManifestLoader( @@ -206,8 +206,8 @@ function getFinalManifestCacheKey(inlineCss: boolean): FinalManifestCacheKey { function cacheFinalManifestPromise( cachedFinalManifestPromises: FinalManifestCache, cacheKey: FinalManifestCacheKey, - promise: Promise, -): Promise { + promise: Promise, +): Promise { const cachedFinalManifestPromise = promise.catch((error) => { if ( cachedFinalManifestPromises.get(cacheKey) === cachedFinalManifestPromise @@ -224,8 +224,8 @@ function cacheFinalManifestPromise( function getOrCreateCachedFinalManifestPromise( cachedFinalManifestPromises: FinalManifestCache, cacheKey: FinalManifestCacheKey, - computeFinalManifest: () => Promise, -): Promise { + computeFinalManifest: () => Promise, +): Promise { const cachedFinalManifestPromise = cachedFinalManifestPromises.get(cacheKey) if (cachedFinalManifestPromise) { return cachedFinalManifestPromise @@ -242,7 +242,7 @@ async function buildFinalManifest(opts: { base: StartManifestWithClientEntry transformFn: TransformAssetsFn | undefined inlineCss: boolean -}): Promise { +}): Promise { return opts.transformFn ? await transformManifestAssets(opts.base, opts.transformFn, { inlineCss: opts.inlineCss, @@ -256,7 +256,7 @@ async function resolveFinalManifest(opts: { cache: boolean inlineCss: boolean finalManifestCache?: FinalManifestCache -}): Promise { +}): Promise { const computeFinalManifest = async () => { return buildFinalManifest({ base: await opts.getBaseManifest(), @@ -284,7 +284,7 @@ function warmupFinalManifest(opts: { getBaseManifest: () => Promise getTransformFn: () => Promise onError?: () => void -}): Promise | undefined { +}): Promise | undefined { if ( !opts.enabled || opts.handlerDefaultInlineCss === undefined || diff --git a/packages/start-server-core/src/request-handler.ts b/packages/start-server-core/src/request-handler.ts index e6729cd137..0c491e00c9 100644 --- a/packages/start-server-core/src/request-handler.ts +++ b/packages/start-server-core/src/request-handler.ts @@ -9,7 +9,8 @@ type EarlyHintsOptions = { * Fire-and-forget callback for HTTP 103 Early Hints. * Only invoked in production (when TSS_DEV_SERVER !== 'true'). * - * The `static` phase contains transformed manifest assets for matched routes. + * The `static` phase contains transformed manifest preloads and stylesheets + * for matched routes. * The `dynamic` phase runs after route load, is skipped for redirects, and * can contain route `head().links` or empty `hints` and `links` arrays. * `hints` and `links` contain only values not emitted in earlier phases. diff --git a/packages/start-server-core/src/router-manifest.ts b/packages/start-server-core/src/router-manifest.ts index fa607fdf50..3b7a792aa6 100644 --- a/packages/start-server-core/src/router-manifest.ts +++ b/packages/start-server-core/src/router-manifest.ts @@ -1,8 +1,12 @@ -import { buildDevStylesUrl, rootRouteId } from '@tanstack/router-core' +import { + DEV_STYLES_ATTR, + buildDevStylesUrl, + rootRouteId, +} from '@tanstack/router-core' import type { AnyRoute, ManifestAssetLink, - RouterManagedTag, + ServerManifestRoute, } from '@tanstack/router-core' import type { StartManifestWithClientEntry } from './transformAssetUrls' @@ -10,7 +14,6 @@ import type { StartManifestWithClientEntry } from './transformAssetUrls' // Defaults to vite `base` (set via TSS_DEV_SSR_STYLES_BASEPATH in the plugin), // aligning dev styles with how other CSS/JS assets are served. const DEV_SSR_STYLES_BASEPATH = process.env.TSS_DEV_SSR_STYLES_BASEPATH || '/' - /** * @description Returns the router manifest data that should be sent to the client. * This includes only the assets and preloads for the current route and any @@ -28,14 +31,15 @@ export async function getStartManifest( ): Promise { const { tsrStartManifest } = await import('tanstack-start-manifest:v') const startManifest = tsrStartManifest() + let routes = startManifest.routes + let rootRoute = routes[rootRouteId] - const rootRoute = { - ...startManifest.routes[rootRouteId], - assets: [...(startManifest.routes[rootRouteId]?.assets ?? [])], - } - const routes = { - ...startManifest.routes, - [rootRouteId]: rootRoute, + const updateRootRoute = (nextRootRoute: ServerManifestRoute) => { + rootRoute = nextRootRoute + routes = { + ...routes, + [rootRouteId]: rootRoute, + } } // Inject dev styles link in dev mode (when SSR styles are enabled) @@ -45,25 +49,32 @@ export async function getStartManifest( matchedRoutes ) { const matchedRouteIds = matchedRoutes.map((route) => route.id) - rootRoute.assets.push({ - tag: 'link', - attrs: { - rel: 'stylesheet', - href: buildDevStylesUrl(DEV_SSR_STYLES_BASEPATH, matchedRouteIds), - 'data-tanstack-router-dev-styles': 'true', - }, + updateRootRoute({ + ...rootRoute, + css: [ + ...(rootRoute?.css ?? []), + { + href: buildDevStylesUrl(DEV_SSR_STYLES_BASEPATH, matchedRouteIds), + [DEV_STYLES_ATTR]: true, + }, + ], }) } if (process.env.TSS_DEV_SERVER === 'true') { const mod = await import('tanstack-start-injected-head-scripts:v') if (mod.injectedHeadScripts) { - rootRoute.assets.push({ - tag: 'script', - attrs: { - type: 'module', - }, - children: mod.injectedHeadScripts, + updateRootRoute({ + ...rootRoute, + scripts: [ + ...(rootRoute?.scripts ?? []), + { + attrs: { + type: 'module', + }, + children: mod.injectedHeadScripts, + }, + ], }) } } @@ -72,21 +83,25 @@ export async function getStartManifest( ...(startManifest.scriptFormat ? { scriptFormat: startManifest.scriptFormat } : {}), - inlineCss: startManifest.inlineCss, + ...(startManifest.inlineCss ? { inlineCss: startManifest.inlineCss } : {}), routes: Object.fromEntries( Object.entries(routes).flatMap(([k, v]) => { const result = {} as { preloads?: Array - assets?: Array + scripts?: ServerManifestRoute['scripts'] + css?: ServerManifestRoute['css'] } - const assets = v.assets let hasData = false if (v.preloads && v.preloads.length > 0) { result['preloads'] = v.preloads hasData = true } - if (assets.length > 0) { - result['assets'] = assets + if (v.scripts && v.scripts.length > 0) { + result['scripts'] = v.scripts + hasData = true + } + if (v.css?.length) { + result['css'] = v.css hasData = true } if (!hasData) { diff --git a/packages/start-server-core/src/tanstack-start.d.ts b/packages/start-server-core/src/tanstack-start.d.ts index 3cf079c02a..d4aa2b6d42 100644 --- a/packages/start-server-core/src/tanstack-start.d.ts +++ b/packages/start-server-core/src/tanstack-start.d.ts @@ -1,7 +1,7 @@ declare module 'tanstack-start-manifest:v' { - import type { Manifest } from '@tanstack/router-core' + import type { ServerManifest } from '@tanstack/router-core' - export const tsrStartManifest: () => Manifest & { clientEntry: string } + export const tsrStartManifest: () => ServerManifest & { clientEntry: string } } declare module 'tanstack-start-route-tree:v' { diff --git a/packages/start-server-core/src/transformAssetUrls.ts b/packages/start-server-core/src/transformAssetUrls.ts index 7034f1cf8a..a4d8ebfc13 100644 --- a/packages/start-server-core/src/transformAssetUrls.ts +++ b/packages/start-server-core/src/transformAssetUrls.ts @@ -1,15 +1,17 @@ import { getManifestScriptFormat, resolveManifestAssetLink, + resolveManifestCssLink, } from '@tanstack/router-core' import type { AssetCrossOrigin, Awaitable, - Manifest, ManifestAssetLink, - RouterManagedTag, + ManifestCssLink, + ManifestScript, ScriptFormat, + ServerManifest, } from '@tanstack/router-core' export type { AssetCrossOrigin } @@ -211,7 +213,7 @@ async function transformInlineCssTemplate(options: { } async function transformInlineCssStyles( - inlineCss: NonNullable, + inlineCss: NonNullable, transformFn: TransformAssetsFn, ) { const transformedStyles: Record = {} @@ -321,7 +323,7 @@ export function resolveTransformAssetsConfig( } export interface StartManifestWithClientEntry { - manifest: Manifest + manifest: ServerManifest clientEntry: string } @@ -333,9 +335,8 @@ export function buildClientEntryScriptTag( clientEntry: string, scriptFormat: ScriptFormat = 'module', crossOrigin?: AssetCrossOrigin, -): RouterManagedTag { +): ManifestScript { return { - tag: 'script', attrs: { ...(scriptFormat === 'module' ? { type: 'module' } : {}), async: true, @@ -345,22 +346,36 @@ export function buildClientEntryScriptTag( } } -function assignManifestAssetLink( +type AssignableManifestLink = ManifestAssetLink | ManifestCssLink + +function assignManifestLink( link: ManifestAssetLink, next: { href: string; crossOrigin?: AssetCrossOrigin }, -): ManifestAssetLink { +): ManifestAssetLink +function assignManifestLink( + link: ManifestCssLink, + next: { href: string; crossOrigin?: AssetCrossOrigin }, +): ManifestCssLink +function assignManifestLink( + link: AssignableManifestLink, + next: { href: string; crossOrigin?: AssetCrossOrigin }, +): AssignableManifestLink { if (typeof link === 'string') { return next.crossOrigin ? next : next.href } - return next.crossOrigin ? next : { href: next.href } -} + const nextLink: Exclude = { + ...link, + href: next.href, + } -function buildManifestAssetLink( - href: string, - crossOrigin?: AssetCrossOrigin, -): ManifestAssetLink { - return crossOrigin ? { href, crossOrigin } : href + if (next.crossOrigin) { + nextLink.crossOrigin = next.crossOrigin + } else { + delete nextLink.crossOrigin + } + + return nextLink } function appendUniqueManifestAssetLink( @@ -376,6 +391,34 @@ function appendUniqueManifestAssetLink( return [...(target ?? []), link] } +function addClientEntryToManifest( + manifest: ServerManifest, + clientEntry: string, +) { + const rootRoute = manifest.routes.__root__ ?? {} + const rootScripts = rootRoute.scripts ?? [] + const scripts = rootScripts.some( + (script) => script.attrs?.src === clientEntry, + ) + ? rootScripts + : [ + ...rootScripts, + buildClientEntryScriptTag( + clientEntry, + getManifestScriptFormat(manifest), + ), + ] + + manifest.routes = { + ...manifest.routes, + __root__: { + ...rootRoute, + preloads: appendUniqueManifestAssetLink(rootRoute.preloads, clientEntry), + scripts, + }, + } +} + export async function transformManifestAssets( source: StartManifestWithClientEntry, transformFn: TransformAssetsFn, @@ -383,10 +426,28 @@ export async function transformManifestAssets( clone?: boolean inlineCss?: boolean }, -): Promise { +): Promise { const manifest = structuredClone(source.manifest) const inlineCssEnabled = _opts?.inlineCss !== false - const scriptFormat = getManifestScriptFormat(manifest) + const scriptTransforms = new Map< + string, + Promise> + >() + const transformScript = (url: string) => { + const cached = scriptTransforms.get(url) + if (cached) { + return cached + } + + const transformed = Promise.resolve( + transformFn({ + url, + kind: 'script', + }), + ).then(normalizeTransformAssetResult) + scriptTransforms.set(url, transformed) + return transformed + } if (!inlineCssEnabled) { delete manifest.inlineCss @@ -397,33 +458,35 @@ export async function transformManifestAssets( ) } - const transformedClientEntry = normalizeTransformAssetResult( - await transformFn({ - url: source.clientEntry, - kind: 'script', - }), - ) + addClientEntryToManifest(manifest, source.clientEntry) for (const route of Object.values(manifest.routes)) { if (route.preloads) { route.preloads = await Promise.all( route.preloads.map(async (link) => { const resolved = resolveManifestAssetLink(link) - if (resolved.href === source.clientEntry) { - return assignManifestAssetLink(link, { - href: transformedClientEntry.href, - crossOrigin: transformedClientEntry.crossOrigin, - }) - } + const result = await transformScript(resolved.href) + return assignManifestLink(link, { + href: result.href, + crossOrigin: result.crossOrigin, + }) + }), + ) + } + + if (route.css && !manifest.inlineCss) { + route.css = await Promise.all( + route.css.map(async (link) => { + const resolved = resolveManifestCssLink(link) const result = normalizeTransformAssetResult( await transformFn({ url: resolved.href, - kind: 'script', + kind: 'stylesheet', }), ) - return assignManifestAssetLink(link, { + return assignManifestLink(link, { href: result.href, crossOrigin: result.crossOrigin, }) @@ -431,60 +494,33 @@ export async function transformManifestAssets( ) } - if (route.assets && !manifest.inlineCss) { - for (const asset of route.assets) { - if (asset.tag === 'link' && asset.attrs?.href) { - const rel = asset.attrs.rel - const relTokens = typeof rel === 'string' ? rel.split(/\s+/) : [] - - if (!relTokens.includes('stylesheet')) { - continue - } + if (route.scripts) { + for (const script of route.scripts) { + const src = script.attrs?.src + if (typeof src !== 'string') { + continue + } - const result = normalizeTransformAssetResult( - await transformFn({ - url: asset.attrs.href, - kind: 'stylesheet', - }), - ) + const result = await transformScript(src) - asset.attrs.href = result.href - if (result.crossOrigin) { - asset.attrs.crossOrigin = result.crossOrigin - } else { - delete asset.attrs.crossOrigin - } + script.attrs = { + ...script.attrs, + src: result.href, + } + if (result.crossOrigin) { + script.attrs.crossOrigin = result.crossOrigin + } else { + delete script.attrs.crossOrigin } } } } - const entryScript = buildClientEntryScriptTag( - transformedClientEntry.href, - scriptFormat, - transformedClientEntry.crossOrigin, - ) - const entryPreload = buildManifestAssetLink( - transformedClientEntry.href, - transformedClientEntry.crossOrigin, - ) - const rootRoute = manifest.routes.__root__ ?? {} - - return { - ...manifest, - routes: { - ...manifest.routes, - __root__: { - ...rootRoute, - preloads: appendUniqueManifestAssetLink(rootRoute.preloads, entryPreload), - assets: [...(rootRoute.assets ?? []), entryScript], - }, - }, - } + return manifest } /** - * Builds a final Manifest from a StartManifestWithClientEntry without any + * Builds a final ServerManifest from a StartManifestWithClientEntry without any * URL transforms. Used when no transformAssets option is provided. * * Returns a new manifest object so the cached base manifest is never mutated. @@ -492,31 +528,20 @@ export async function transformManifestAssets( export function buildManifestWithClientEntry( source: StartManifestWithClientEntry, opts?: { inlineCss?: boolean }, -): Manifest { - const scriptFormat = getManifestScriptFormat(source.manifest) - const entryScript = buildClientEntryScriptTag( - source.clientEntry, - scriptFormat, - ) - const rootRoute = source.manifest.routes.__root__ ?? {} - - return { +): ServerManifest { + const manifest: ServerManifest = { ...(source.manifest.scriptFormat ? { scriptFormat: source.manifest.scriptFormat } : {}), - ...(opts?.inlineCss === false - ? {} - : { inlineCss: structuredClone(source.manifest.inlineCss) }), + ...(opts?.inlineCss !== false && source.manifest.inlineCss + ? { inlineCss: structuredClone(source.manifest.inlineCss) } + : {}), routes: { ...source.manifest.routes, - __root__: { - ...rootRoute, - preloads: appendUniqueManifestAssetLink( - rootRoute.preloads, - source.clientEntry, - ), - assets: [...(rootRoute.assets ?? []), entryScript], - }, }, } + + addClientEntryToManifest(manifest, source.clientEntry) + + return manifest } diff --git a/packages/start-server-core/tests/early-hints.test.ts b/packages/start-server-core/tests/early-hints.test.ts index c4b47ed1ca..127bcbb636 100644 --- a/packages/start-server-core/tests/early-hints.test.ts +++ b/packages/start-server-core/tests/early-hints.test.ts @@ -8,7 +8,11 @@ import { serializeEarlyHint, } from '../src/early-hints' import type { EarlyHint } from '../src/early-hints' -import type { AnyRoute, AnyRouteMatch, Manifest } from '@tanstack/router-core' +import type { + AnyRoute, + AnyRouteMatch, + ServerManifest, +} from '@tanstack/router-core' describe('early hints', () => { afterEach(() => { @@ -51,45 +55,23 @@ describe('early hints', () => { }) it('collects static route JS and stylesheet hints with crossOrigin', () => { - const manifest: Manifest = { + const manifest: ServerManifest = { routes: { __root__: { preloads: [ '/assets/root.js', { href: '/assets/shared.js', crossOrigin: 'anonymous' }, ], - assets: [ + css: [ { - tag: 'link', - attrs: { - rel: 'stylesheet preload', - href: '/assets/root.css', - crossOrigin: 'use-credentials', - type: 'text/css', - media: 'print', - integrity: 'sha256-root', - referrerPolicy: 'no-referrer', - fetchPriority: 'high', - }, - }, - { - tag: 'link', - attrs: { rel: 'icon', href: '/favicon.ico' }, + href: '/assets/root.css', + crossOrigin: 'use-credentials', }, ], }, '/posts': { preloads: ['/assets/posts.js'], - assets: [ - { - tag: 'link', - attrs: { rel: 'modulepreload', href: '/assets/posts-extra.js' }, - }, - { - tag: 'link', - attrs: { rel: 'stylesheet', href: '/assets/posts.css' }, - }, - ], + css: ['/assets/posts.css'], }, }, } @@ -112,19 +94,14 @@ describe('early hints', () => { rel: 'preload', as: 'style', crossOrigin: 'use-credentials', - type: 'text/css', - integrity: 'sha256-root', - referrerPolicy: 'no-referrer', - fetchPriority: 'high', }, { href: '/assets/posts.js', rel: 'modulepreload', as: 'script' }, - { href: '/assets/posts-extra.js', rel: 'modulepreload', as: 'script' }, { href: '/assets/posts.css', rel: 'preload', as: 'style' }, ]) }) it('skips static stylesheet hints when inline CSS is enabled', () => { - const manifest: Manifest = { + const manifest: ServerManifest = { inlineCss: { styles: { '/assets/posts.css': '.posts{}', @@ -133,12 +110,7 @@ describe('early hints', () => { routes: { '/posts': { preloads: ['/assets/posts.js'], - assets: [ - { - tag: 'link', - attrs: { rel: 'stylesheet', href: '/assets/posts.css' }, - }, - ], + css: ['/assets/posts.css'], }, }, } @@ -153,7 +125,7 @@ describe('early hints', () => { }) it('collects static route JS hints as preload script for iife manifests', () => { - const manifest: Manifest = { + const manifest: ServerManifest = { scriptFormat: 'iife', routes: { '/posts': { @@ -376,7 +348,7 @@ describe('early hints', () => { }, responseLinkHeader: true, })! - const manifest: Manifest = { + const manifest: ServerManifest = { routes: { __root__: { preloads: ['/assets/app.js'], diff --git a/packages/start-server-core/tests/finalManifest.test.ts b/packages/start-server-core/tests/finalManifest.test.ts index fedd4df1b9..1ba9d18c89 100644 --- a/packages/start-server-core/tests/finalManifest.test.ts +++ b/packages/start-server-core/tests/finalManifest.test.ts @@ -124,7 +124,7 @@ describe('final manifest resolver', () => { await expect(warmupPromise).resolves.toBe(requestManifest) expect(requestManifest.inlineCss).toBeUndefined() - expect(transformAssets).toHaveBeenCalledTimes(1) + expect(transformAssets).toHaveBeenCalledTimes(2) }) it('does not warm up when the inline CSS default is request-dependent', () => { @@ -157,8 +157,7 @@ describe('final manifest resolver', () => { }) expect(manifest.inlineCss).toBeDefined() - expect(manifest.routes.__root__?.assets?.at(-1)).toMatchObject({ - tag: 'script', + expect(manifest.routes.__root__?.scripts?.at(-1)).toMatchObject({ attrs: { type: 'module', async: true, diff --git a/packages/start-server-core/tests/transformAssets.test.ts b/packages/start-server-core/tests/transformAssets.test.ts index f6f39b310c..c943588d66 100644 --- a/packages/start-server-core/tests/transformAssets.test.ts +++ b/packages/start-server-core/tests/transformAssets.test.ts @@ -4,6 +4,7 @@ import { resolveTransformAssetsConfig, transformManifestAssets, } from '../src/transformAssetUrls' +import type { StartManifestWithClientEntry } from '../src/transformAssetUrls' describe('transformAssets', () => { it('supports string shorthand', async () => { @@ -29,12 +30,7 @@ describe('transformAssets', () => { routes: { __root__: { preloads: ['/assets/app.js'], - assets: [ - { - tag: 'link', - attrs: { rel: 'stylesheet', href: '/assets/app.css' }, - }, - ], + css: ['/assets/app.css'], }, }, }, @@ -63,13 +59,9 @@ describe('transformAssets', () => { crossOrigin: 'anonymous', }, ]) - expect(manifest.routes.__root__?.assets?.[0]).toEqual({ - tag: 'link', - attrs: { - rel: 'stylesheet', - href: 'https://cdn.example.com/assets/app.css', - }, - }) + expect(manifest.routes.__root__?.css?.[0]).toBe( + 'https://cdn.example.com/assets/app.css', + ) }) it('preserves string preload format when transform returns no crossOrigin', async () => { @@ -79,7 +71,6 @@ describe('transformAssets', () => { routes: { __root__: { preloads: ['/assets/app.js'], - assets: [], }, }, }, @@ -98,7 +89,7 @@ describe('transformAssets', () => { ) }) - it('passes script context for route preloads and client entry', async () => { + it('passes script context for route preloads and client entry assets', async () => { const transformFn = vi.fn(({ url }) => ({ href: `https://cdn.example.com${url}`, })) @@ -127,14 +118,18 @@ describe('transformAssets', () => { url: '/assets/entry.js', }) expect(transformFn).toHaveBeenCalledTimes(2) + expect(transformFn.mock.calls).toEqual([ + [{ kind: 'script', url: '/assets/app.js' }], + [{ kind: 'script', url: '/assets/entry.js' }], + ]) }) - it('transforms client entry once when it already exists in root preloads', async () => { + it('does not duplicate the client entry preload when it already exists', async () => { const transformFn = vi.fn(({ url }) => ({ href: `https://cdn.example.com${url}`, })) - await transformManifestAssets( + const manifest = await transformManifestAssets( { manifest: { routes: { @@ -148,29 +143,99 @@ describe('transformAssets', () => { transformFn, ) + expect(manifest.routes.__root__?.preloads).toEqual([ + 'https://cdn.example.com/assets/entry.js', + ]) expect(transformFn).toHaveBeenCalledTimes(1) - expect(transformFn).toHaveBeenCalledWith({ - kind: 'script', - url: '/assets/entry.js', - }) + expect(transformFn.mock.calls).toEqual([ + [{ kind: 'script', url: '/assets/entry.js' }], + ]) }) - it('adds external client entry script tags to root assets for module and iife formats', () => { - const moduleManifest = buildManifestWithClientEntry({ - manifest: { routes: { __root__: {} } }, - clientEntry: '/assets/entry.js', - }) + it('reuses the transformed client entry URL for matching preload and script tags', async () => { + let signature = 0 + const transformFn = vi.fn(({ url }) => ({ + href: `https://cdn.example.com${url}?sig=${++signature}`, + })) + + const manifest = await transformManifestAssets( + { + manifest: { + routes: { + __root__: {}, + }, + }, + clientEntry: '/assets/entry.js', + }, + transformFn, + ) + + const preload = manifest.routes.__root__?.preloads?.[0] + const script = manifest.routes.__root__?.scripts?.[0] + expect(preload).toBe(script?.attrs?.src) + expect(transformFn.mock.calls).toEqual([ + [{ kind: 'script', url: '/assets/entry.js' }], + ]) + }) + + it('does not duplicate the client entry script when it already exists', async () => { + let signature = 0 + const transformFn = vi.fn(({ url }) => ({ + href: `https://cdn.example.com${url}?sig=${++signature}`, + })) - expect(moduleManifest.routes.__root__?.assets?.at(-1)).toEqual( + const manifest = await transformManifestAssets( + { + manifest: { + routes: { + __root__: { + scripts: [ + { + attrs: { + type: 'module', + async: true, + src: '/assets/entry.js', + }, + }, + ], + }, + }, + }, + clientEntry: '/assets/entry.js', + }, + transformFn, + ) + + expect(manifest.routes.__root__?.preloads).toEqual([ + 'https://cdn.example.com/assets/entry.js?sig=1', + ]) + expect(manifest.routes.__root__?.scripts).toEqual([ { - tag: 'script', attrs: { type: 'module', async: true, - src: '/assets/entry.js', + src: 'https://cdn.example.com/assets/entry.js?sig=1', }, }, - ) + ]) + expect(transformFn.mock.calls).toEqual([ + [{ kind: 'script', url: '/assets/entry.js' }], + ]) + }) + + it('adds external client entry script tags to root scripts for module and iife formats', () => { + const moduleManifest = buildManifestWithClientEntry({ + manifest: { routes: { __root__: {} } }, + clientEntry: '/assets/entry.js', + }) + + expect(moduleManifest.routes.__root__?.scripts?.at(-1)).toEqual({ + attrs: { + type: 'module', + async: true, + src: '/assets/entry.js', + }, + }) expect(moduleManifest.routes.__root__?.preloads).toEqual([ '/assets/entry.js', ]) @@ -180,33 +245,23 @@ describe('transformAssets', () => { clientEntry: '/assets/entry.js', }) - expect(iifeManifest.routes.__root__?.assets?.at(-1)).toEqual( - { - tag: 'script', - attrs: { - async: true, - src: '/assets/entry.js', - }, + expect(iifeManifest.routes.__root__?.scripts?.at(-1)).toEqual({ + attrs: { + async: true, + src: '/assets/entry.js', }, - ) - expect(iifeManifest.routes.__root__?.preloads).toEqual([ - '/assets/entry.js', - ]) + }) + expect(iifeManifest.routes.__root__?.preloads).toEqual(['/assets/entry.js']) }) it('does not mutate the source manifest when clone is false', async () => { - const source = { + const source: StartManifestWithClientEntry = { manifest: { routes: { - __root__: { - preloads: ['/assets/app.js'], - assets: [ - { - tag: 'link' as const, - attrs: { rel: 'stylesheet', href: '/assets/app.css' }, - }, - ], - }, + __root__: { + preloads: ['/assets/app.js'], + css: ['/assets/app.css'], + }, }, }, clientEntry: '/assets/entry.js', @@ -224,13 +279,10 @@ describe('transformAssets', () => { expect(source.manifest.routes.__root__?.preloads?.[0]).toBe( '/assets/app.js', ) - expect(source.manifest.routes.__root__?.assets?.[0]).toEqual({ - tag: 'link', - attrs: { rel: 'stylesheet', href: '/assets/app.css' }, - }) + expect(source.manifest.routes.__root__?.css?.[0]).toBe('/assets/app.css') }) - it('only treats stylesheet links in route.assets as stylesheet transforms', async () => { + it('transforms manifest stylesheet links', async () => { const transformFn = vi.fn(({ url }) => ({ href: `https://cdn.example.com${url}`, })) @@ -241,16 +293,7 @@ describe('transformAssets', () => { routes: { __root__: { preloads: [], - assets: [ - { - tag: 'link', - attrs: { rel: 'stylesheet preload', href: '/assets/app.css' }, - }, - { - tag: 'link', - attrs: { rel: 'icon', href: '/favicon.ico' }, - }, - ], + css: ['/assets/app.css'], }, }, }, @@ -264,27 +307,11 @@ describe('transformAssets', () => { kind: 'stylesheet', url: '/assets/app.css', }) - expect(transformFn).not.toHaveBeenCalledWith({ - kind: 'stylesheet', - url: '/favicon.ico', - }) - expect(manifest.routes.__root__?.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet preload', - href: 'https://cdn.example.com/assets/app.css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'icon', - href: '/favicon.ico', - }, - }, + expect(manifest.routes.__root__?.css).toEqual([ + 'https://cdn.example.com/assets/app.css', + ]) + expect(manifest.routes.__root__?.scripts).toEqual([ { - tag: 'script', attrs: { type: 'module', async: true, @@ -292,16 +319,44 @@ describe('transformAssets', () => { }, }, ]) - expect(manifest.routes.__root__?.assets?.at(-1)).toEqual( + expect(manifest.routes.__root__?.scripts?.at(-1)).toEqual({ + attrs: { + type: 'module', + async: true, + src: 'https://cdn.example.com/assets/entry.js', + }, + }) + }) + + it('transforms existing manifest route script src values', async () => { + const manifest = await transformManifestAssets( { - tag: 'script', - attrs: { - type: 'module', - async: true, - src: 'https://cdn.example.com/assets/entry.js', + manifest: { + routes: { + __root__: { + scripts: [ + { + attrs: { + src: '/assets/route-script.js', + type: 'module', + }, + }, + ], + }, + }, }, + clientEntry: '/assets/entry.js', }, + ({ url }) => ({ href: `https://cdn.example.com${url}` }), + { clone: true }, ) + + expect(manifest.routes.__root__?.scripts?.[0]).toEqual({ + attrs: { + src: 'https://cdn.example.com/assets/route-script.js', + type: 'module', + }, + }) }) it('transforms CSS URLs inside inlined stylesheet templates with css-url context', async () => { @@ -334,12 +389,7 @@ describe('transformAssets', () => { }, routes: { __root__: { - assets: [ - { - tag: 'link', - attrs: { rel: 'stylesheet', href: '/assets/app.css' }, - }, - ], + css: ['/assets/app.css'], }, }, }, @@ -419,12 +469,7 @@ describe('transformAssets', () => { }, routes: { __root__: { - assets: [ - { - tag: 'link', - attrs: { rel: 'stylesheet', href: '/assets/app.css' }, - }, - ], + css: ['/assets/app.css'], }, }, }, @@ -433,10 +478,7 @@ describe('transformAssets', () => { transformFn, ) - expect(manifest.routes.__root__?.assets?.[0]).toEqual({ - tag: 'link', - attrs: { rel: 'stylesheet', href: '/assets/app.css' }, - }) + expect(manifest.routes.__root__?.css?.[0]).toBe('/assets/app.css') expect(transformFn).not.toHaveBeenCalledWith({ kind: 'stylesheet', url: '/assets/app.css', @@ -653,11 +695,12 @@ describe('transformAssets', () => { ).toEqual({ href: '/assets/app.js' }) }) - it('applies object shorthand crossOrigin to manifest assets', async () => { + it('applies object shorthand crossOrigin to manifest stylesheets and preloads', async () => { const config = resolveTransformAssetsConfig({ prefix: 'https://cdn.example.com', crossOrigin: { script: 'anonymous', + stylesheet: 'use-credentials', }, }) @@ -669,12 +712,7 @@ describe('transformAssets', () => { routes: { __root__: { preloads: ['/assets/app.js'], - assets: [ - { - tag: 'link', - attrs: { rel: 'stylesheet', href: '/assets/app.css' }, - }, - ], + css: ['/assets/app.css'], }, }, }, @@ -695,13 +733,9 @@ describe('transformAssets', () => { }, ]) - // Stylesheet has no crossOrigin in the per-kind config - expect(manifest.routes.__root__?.assets?.[0]).toEqual({ - tag: 'link', - attrs: { - rel: 'stylesheet', - href: 'https://cdn.example.com/assets/app.css', - }, + expect(manifest.routes.__root__?.css?.[0]).toEqual({ + href: 'https://cdn.example.com/assets/app.css', + crossOrigin: 'use-credentials', }) }) }) diff --git a/packages/start-storage-context/src/async-local-storage.ts b/packages/start-storage-context/src/async-local-storage.ts index 88d4ed3ca5..21aabd6cdf 100644 --- a/packages/start-storage-context/src/async-local-storage.ts +++ b/packages/start-storage-context/src/async-local-storage.ts @@ -1,8 +1,8 @@ import { AsyncLocalStorage } from 'node:async_hooks' import type { Awaitable, + ManifestRouteAssets, RegisteredRouter, - RouterManagedTag, } from '@tanstack/router-core' export type StartHandlerType = 'router' | 'serverFn' @@ -22,10 +22,10 @@ export interface StartStorageContext { /** * Additional assets to inject for this request. - * Plugins can push RouterManagedTag items here during request processing. + * Plugins can add manifest route assets here during request processing. * Merged into manifest at dehydration time without mutating cached manifest. */ - requestAssets?: Array + requestAssets?: ManifestRouteAssets } // Use a global symbol to ensure the same AsyncLocalStorage instance is shared diff --git a/packages/vue-router/src/HeadContent.dev.tsx b/packages/vue-router/src/HeadContent.dev.tsx index 762cc0e735..e3a5c88294 100644 --- a/packages/vue-router/src/HeadContent.dev.tsx +++ b/packages/vue-router/src/HeadContent.dev.tsx @@ -1,12 +1,11 @@ import * as Vue from 'vue' +import { DEV_STYLES_ATTR } from '@tanstack/router-core' import { Asset } from './Asset' import { useHydrated } from './ClientOnly' import { useTags } from './headContentUtils' import type { AssetCrossOriginConfig } from '@tanstack/router-core' -const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles' - /** * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route. * It should be rendered in the `` of your document. @@ -35,7 +34,10 @@ export const HeadContent = Vue.defineComponent({ return () => { // Filter out dev styles after hydration const filteredTags = hydrated.value - ? tags().filter((tag) => !tag.attrs?.[DEV_STYLES_ATTR]) + ? tags().filter( + (tag) => + tag.tag !== 'link' || tag.attrs?.[DEV_STYLES_ATTR] !== true, + ) : tags() return filteredTags.map((tag) => diff --git a/packages/vue-router/src/HeadContent.tsx b/packages/vue-router/src/HeadContent.tsx index c307f866f2..b69ac79b72 100644 --- a/packages/vue-router/src/HeadContent.tsx +++ b/packages/vue-router/src/HeadContent.tsx @@ -1,8 +1,109 @@ import * as Vue from 'vue' +import { isServer } from '@tanstack/router-core/isServer' import { Asset } from './Asset' import { useTags } from './headContentUtils' -import type { AssetCrossOriginConfig } from '@tanstack/router-core' +import { useRouter } from './useRouter' +import type { + AssetCrossOriginConfig, + RouterManagedTag, +} from '@tanstack/router-core' + +function attrsMatch(attrs: Record, element: Element) { + let expectedAttrCount = 0 + + for (const name in attrs) { + const value = attrs[name] + if (value === undefined || value === false || value === null) { + continue + } + + expectedAttrCount++ + const attrName = name.toLowerCase() + + if (value === true) { + if (!element.hasAttribute(attrName)) { + return false + } + + continue + } + + if (element.getAttribute(attrName) !== String(value)) { + return false + } + } + + return expectedAttrCount === element.getAttributeNames().length +} + +function reconcileHydratedHead( + tags: Array, + preservedHeadTagElements: Record>, +) { + if (typeof document === 'undefined') { + return + } + + const matchedHeadElements = new Set() + const hydratedLinks = document.head.querySelectorAll('link') + + for (const tag of tags) { + if (tag.tag !== 'link') { + continue + } + + const attrs = tag.attrs + const rel = + typeof attrs?.rel === 'string' ? attrs.rel.toLowerCase() : undefined + if (rel !== 'stylesheet' && rel !== 'preload' && rel !== 'modulepreload') { + continue + } + + for (const element of hydratedLinks) { + if ( + !matchedHeadElements.has(element) && + attrsMatch(attrs!, element) + ) { + matchedHeadElements.add(element) + const key = JSON.stringify(tag) + ;(preservedHeadTagElements[key] ||= []).push(element) + break + } + } + } +} + +function cleanupInactivePreservedHeadElements( + preservedHeadTagElements: Record>, + activeElements: Set, +) { + for (const key in preservedHeadTagElements) { + const elements = preservedHeadTagElements[key]! + let nextElements: Array | undefined + + for (const element of elements) { + if (activeElements.has(element)) { + ;(nextElements ||= []).push(element) + } else if (!shouldRemoveInactivePreservedHeadElement(element)) { + ;(nextElements ||= []).push(element) + } else { + element.remove() + } + } + + if (nextElements) { + preservedHeadTagElements[key] = nextElements + } else { + delete preservedHeadTagElements[key] + } + } +} + +function shouldRemoveInactivePreservedHeadElement(element: Element) { + const rel = element.getAttribute('rel')?.toLowerCase() + return rel === 'preload' || rel === 'modulepreload' +} export interface HeadContentProps { assetCrossOrigin?: AssetCrossOriginConfig @@ -21,15 +122,61 @@ export const HeadContent = Vue.defineComponent({ }, }, setup(props) { + const router = useRouter() const tags = useTags(props.assetCrossOrigin) + if (isServer ?? router.isServer) { + return () => { + return tags().map((tag) => { + const key = JSON.stringify(tag) + return Vue.h(Asset, { + ...tag, + key: `tsr-meta-${key}`, + }) + }) + } + } + + const preservedHeadTagElements: Record> = {} + let activePreservedHeadElements = new Set() + + if (router.ssr) { + reconcileHydratedHead(tags(), preservedHeadTagElements) + } + + Vue.onUpdated(() => { + cleanupInactivePreservedHeadElements( + preservedHeadTagElements, + activePreservedHeadElements, + ) + }) + Vue.onUnmounted(() => { + cleanupInactivePreservedHeadElements( + preservedHeadTagElements, + new Set(), + ) + }) + return () => { - return tags().map((tag) => - Vue.h(Asset, { + const renderedPreservedHeadTagKeys: Record = {} + activePreservedHeadElements = new Set() + const renderedTags = tags().map((tag) => { + const key = JSON.stringify(tag) + const renderedCount = renderedPreservedHeadTagKeys[key] || 0 + const preservedElement = preservedHeadTagElements[key]?.[renderedCount] + if (preservedElement?.isConnected) { + renderedPreservedHeadTagKeys[key] = renderedCount + 1 + activePreservedHeadElements.add(preservedElement) + return null + } + + return Vue.h(Asset, { ...tag, - key: `tsr-meta-${JSON.stringify(tag)}`, - }), - ) + key: `tsr-meta-${key}`, + }) + }) + + return renderedTags } }, }) diff --git a/packages/vue-router/src/Scripts.tsx b/packages/vue-router/src/Scripts.tsx index b326370c7c..3b2f4a5ff7 100644 --- a/packages/vue-router/src/Scripts.tsx +++ b/packages/vue-router/src/Scripts.tsx @@ -27,23 +27,19 @@ export const Scripts = Vue.defineComponent({ return [] } - matches - .map((match) => router.looseRoutesById[match.routeId]!) - .forEach((route) => { - const routeManifest = manifest.routes[route.id] - - routeManifest?.assets - ?.filter((d) => d.tag === 'script') - .forEach((asset) => { - const scriptAsset = { - tag: 'script', - attrs: { ...asset.attrs, nonce }, - children: asset.children, - } satisfies RouterManagedTag - - assetScripts.push(scriptAsset) - }) + matches.forEach((match) => { + const routeManifest = manifest.routes[match.routeId] + + routeManifest?.scripts?.forEach((asset) => { + const scriptAsset = { + tag: 'script', + attrs: { ...asset.attrs, nonce }, + children: asset.children, + } satisfies RouterManagedTag + + assetScripts.push(scriptAsset) }) + }) return assetScripts } diff --git a/packages/vue-router/src/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx index dc87698b22..1f1f8ff973 100644 --- a/packages/vue-router/src/headContentUtils.tsx +++ b/packages/vue-router/src/headContentUtils.tsx @@ -1,9 +1,10 @@ import * as Vue from 'vue' import { + appendUniqueUserTags, escapeHtml, getAssetCrossOrigin, getScriptPreloadAttrs, - isInlinableStylesheet, + resolveManifestCssLink, } from '@tanstack/router-core' import { useStore } from '@tanstack/vue-store' import { useRouter } from './useRouter' @@ -94,24 +95,22 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const preloadMeta = Vue.computed>(() => { const preloadMeta: Array = [] - matches.value - .map((match) => router.looseRoutesById[match.routeId]!) - .forEach((route) => - router.ssr?.manifest?.routes[route.id]?.preloads - ?.filter(Boolean) - .forEach((preload) => { - preloadMeta.push({ - tag: 'link', - attrs: { - ...getScriptPreloadAttrs( - router.ssr?.manifest, - preload, - assetCrossOrigin, - ), - }, - }) - }), - ) + matches.value.forEach((match) => { + router.ssr?.manifest?.routes[match.routeId]?.preloads + ?.filter(Boolean) + .forEach((preload) => { + preloadMeta.push({ + tag: 'link', + attrs: { + ...getScriptPreloadAttrs( + router.ssr?.manifest, + preload, + assetCrossOrigin, + ), + }, + }) + }) + }) return preloadMeta }) @@ -134,69 +133,45 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const manifestAssets = Vue.computed>(() => { const manifest = router.ssr?.manifest - const assets = matches.value - .map((match) => manifest?.routes[match.routeId]?.assets ?? []) - .filter(Boolean) - .flat(1) - .flatMap((asset): Array => { - if (asset.tag === 'link') { - if (isInlinableStylesheet(manifest, asset)) { - return [] - } + const assets: Array = [] - return [ - { - tag: 'link', - attrs: { - ...asset.attrs, - crossOrigin: - getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? - asset.attrs?.crossOrigin, - }, - }, - ] - } - - if (asset.tag === 'style') { - return [ - { - tag: 'style', - attrs: asset.attrs, - children: asset.children, - ...(asset.inlineCss ? { inlineCss: true as const } : {}), - }, - ] - } + matches.value.forEach((match) => { + const routeManifest = manifest?.routes[match.routeId] - return [] + routeManifest?.css?.forEach((link) => { + const resolvedLink = resolveManifestCssLink(link) + assets.push({ + tag: 'link', + attrs: { + rel: 'stylesheet', + ...resolvedLink, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? + resolvedLink.crossOrigin, + }, + }) }) + }) + + if (manifest?.inlineStyle) { + assets.push({ + tag: 'style', + attrs: manifest.inlineStyle.attrs, + children: manifest.inlineStyle.children, + inlineCss: true, + }) + } return assets }) - return () => - uniqBy( - [ - ...manifestAssets.value, - ...meta.value, - ...preloadMeta.value, - ...links.value, - ...headScripts.value, - ] as Array, - (d) => { - return JSON.stringify(d) - }, - ) -} - -export function uniqBy(arr: Array, fn: (item: T) => string) { - const seen = new Set() - return arr.filter((item) => { - const key = fn(item) - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) + return () => { + const tags: Array = [] + tags.push(...manifestAssets.value) + appendUniqueUserTags(tags, meta.value) + tags.push(...preloadMeta.value) + appendUniqueUserTags(tags, links.value) + appendUniqueUserTags(tags, headScripts.value) + return tags + } } diff --git a/packages/vue-router/tests/Scripts.test.tsx b/packages/vue-router/tests/Scripts.test.tsx index d40384f036..75f0aa4953 100644 --- a/packages/vue-router/tests/Scripts.test.tsx +++ b/packages/vue-router/tests/Scripts.test.tsx @@ -31,15 +31,7 @@ const createTestManifest = ( routes: { [routeId]: { preloads: ['/main.js'], - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/main.css', - }, - }, - ], + css: ['/main.css'], }, }, }) satisfies Manifest @@ -156,7 +148,7 @@ describe('ssr scripts', () => { }) describe('ssr HeadContent', () => { - test('applies assetCrossOrigin to manifest assets and preloads', async () => { + test('applies assetCrossOrigin to manifest stylesheets and preloads', async () => { const history = createTestBrowserHistory() const rootRoute = createRootRoute({ @@ -213,6 +205,372 @@ describe('ssr HeadContent', () => { ).toBe('anonymous') }) + test( + 'does not duplicate SSR head links and cleans up preloads on unmount', + async () => { + const history = createTestBrowserHistory() + + const ssrStylesheet = document.createElement('link') + ssrStylesheet.setAttribute('rel', 'stylesheet') + ssrStylesheet.setAttribute('href', '/main.css') + + const ssrPreload = document.createElement('link') + ssrPreload.setAttribute('rel', 'modulepreload') + ssrPreload.setAttribute('href', '/main.js') + + document.head.append(ssrStylesheet, ssrPreload) + + try { + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } + + await router.load() + + const { unmount } = render() + + await waitFor(() => { + expect( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/main.css"]', + ), + ).toHaveLength(1) + expect( + document.head.querySelectorAll( + 'link[rel="modulepreload"][href="/main.js"]', + ), + ).toHaveLength(1) + }) + + expect( + document.head.querySelector( + 'link[rel="stylesheet"][href="/main.css"]', + ), + ).toBe(ssrStylesheet) + expect( + document.head.querySelector( + 'link[rel="modulepreload"][href="/main.js"]', + ), + ).toBe(ssrPreload) + + unmount() + + await waitFor(() => { + expect( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/main.css"]', + ), + ).toHaveLength(1) + expect( + document.head.querySelectorAll( + 'link[rel="modulepreload"][href="/main.js"]', + ), + ).toHaveLength(0) + }) + } finally { + ssrStylesheet.remove() + ssrPreload.remove() + } + }, + ) + + test('removes preserved SSR-rendered route preload links after navigation', async () => { + const history = createTestBrowserHistory() + + const ssrStylesheet = document.createElement('link') + ssrStylesheet.setAttribute('rel', 'stylesheet') + ssrStylesheet.setAttribute('href', '/index.css') + + const ssrPreload = document.createElement('link') + ssrPreload.setAttribute('rel', 'modulepreload') + ssrPreload.setAttribute('href', '/index.js') + + document.head.append(ssrStylesheet, ssrPreload) + + try { + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => Go to about page, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () =>
About
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + }) + + router.ssr = { + manifest: { + routes: { + [indexRoute.id]: { + css: ['/index.css'], + preloads: ['/index.js'], + }, + }, + }, + } + + await router.load() + + render() + + await waitFor(() => { + expect( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/index.css"]', + ), + ).toHaveLength(1) + expect( + document.head.querySelectorAll( + 'link[rel="modulepreload"][href="/index.js"]', + ), + ).toHaveLength(1) + }) + + await fireEvent.click( + screen.getByRole('link', { name: 'Go to about page' }), + ) + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/about') + }) + + await waitFor(() => { + expect( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/index.css"]', + ), + ).toHaveLength(1) + expect( + document.head.querySelectorAll( + 'link[rel="modulepreload"][href="/index.js"]', + ), + ).toHaveLength(0) + }) + } finally { + document.head + .querySelectorAll( + 'link[rel="stylesheet"][href="/index.css"], link[rel="modulepreload"][href="/index.js"]', + ) + .forEach((element) => element.remove()) + } + }) + + test('does not reuse one SSR-rendered head link for multiple managed tags', async () => { + const history = createTestBrowserHistory() + + const ssrStylesheet = document.createElement('link') + ssrStylesheet.setAttribute('rel', 'stylesheet') + ssrStylesheet.setAttribute('href', '/main.css') + + document.head.append(ssrStylesheet) + + try { + const rootRoute = createRootRoute({ + head: () => ({ + links: [{ rel: 'stylesheet', href: '/main.css' }], + }), + component: () => ( + <> + + + + + + ), + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } + + await router.load() + + render() + + await waitFor(() => { + expect( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/main.css"]', + ), + ).toHaveLength(2) + }) + + expect( + document.head.querySelector('link[rel="stylesheet"][href="/main.css"]'), + ).toBe(ssrStylesheet) + } finally { + document.head + .querySelectorAll('link[rel="stylesheet"][href="/main.css"]') + .forEach((element) => element.remove()) + } + }) + + test('does not preserve an SSR-rendered head link with stale attrs', async () => { + const history = createTestBrowserHistory() + + const ssrStylesheet = document.createElement('link') + ssrStylesheet.setAttribute('rel', 'stylesheet') + ssrStylesheet.setAttribute('href', '/main.css') + ssrStylesheet.setAttribute('crossorigin', 'anonymous') + + document.head.append(ssrStylesheet) + + try { + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } + + await router.load() + + render() + + await waitFor(() => { + expect( + document.head.querySelector( + 'link[rel="stylesheet"][href="/main.css"][crossorigin="use-credentials"]', + ), + ).toBeTruthy() + }) + + expect( + document.head.querySelector( + 'link[rel="stylesheet"][href="/main.css"][crossorigin="anonymous"]', + ), + ).toBe(ssrStylesheet) + } finally { + document.head + .querySelectorAll('link[rel="stylesheet"][href="/main.css"]') + .forEach((element) => element.remove()) + } + }) + + test('renders runtime manifest inlineStyle', async () => { + const history = createTestBrowserHistory() + + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: { + inlineStyle: { + attrs: { id: 'runtime-inline-style' }, + children: '.runtime{color:red}', + }, + routes: { + [rootRoute.id]: {}, + }, + }, + } + + await router.load() + + render() + + await waitFor(() => { + expect( + document.head.querySelector('style#runtime-inline-style'), + ).toBeTruthy() + }) + + expect( + document.head.querySelector('style#runtime-inline-style')?.textContent, + ).toBe('.runtime{color:red}') + }) + test('renders preload as script links for iife manifest preloads', async () => { const history = createTestBrowserHistory() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f08d5e6a7d..3413cdb95d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2065,6 +2065,9 @@ importers: '@playwright/test': specifier: ^1.57.0 version: 1.58.0 + '@tanstack/router-core': + specifier: workspace:* + version: link:../../../packages/router-core '@tanstack/router-e2e-utils': specifier: workspace:* version: link:../../e2e-utils From 23ec236110944e0313e975a8cfc90fe4c1dd4cdc Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 24 May 2026 01:46:03 +0200 Subject: [PATCH 03/10] optimize --- packages/react-router/src/Asset.tsx | 89 +++---- .../src/serialization.server.ts | 32 ++- .../src/ssr/createRequestHandler.ts | 7 +- packages/router-core/src/ssr/ssr-server.ts | 91 +++++--- .../tests/ssr-server-manifest.test.ts | 94 ++++++-- .../start-plugin-core/src/rsbuild/planning.ts | 2 +- .../start-plugin-core/src/rsbuild/plugin.ts | 67 +++--- .../src/rsbuild/virtual-modules.ts | 12 +- .../start-manifest-plugin/manifestBuilder.ts | 30 +-- packages/start-server-core/src/early-hints.ts | 2 +- .../start-server-core/src/router-manifest.ts | 53 ++--- .../src/transformAssetUrls.ts | 16 +- .../tests/early-hints.test.ts | 29 +++ .../tests/finalManifest.test.ts | 2 +- .../tests/transformAssets.test.ts | 42 +++- packages/vue-router/src/HeadContent.tsx | 7 +- packages/vue-router/src/Scripts.tsx | 6 +- packages/vue-router/tests/Scripts.test.tsx | 217 +++++++++++------- 18 files changed, 510 insertions(+), 288 deletions(-) diff --git a/packages/react-router/src/Asset.tsx b/packages/react-router/src/Asset.tsx index d37f4aa993..5e4f159f52 100644 --- a/packages/react-router/src/Asset.tsx +++ b/packages/react-router/src/Asset.tsx @@ -16,6 +16,25 @@ interface ScriptAttrs { const noopScriptHandler = () => {} +function setScriptAttrs( + script: HTMLScriptElement, + attrs: ScriptAttrs | undefined, +) { + if (!attrs) { + return + } + + for (const [key, value] of Object.entries(attrs)) { + if ( + key !== 'suppressHydrationWarning' && + value !== undefined && + value !== false + ) { + script.setAttribute(key, typeof value === 'boolean' ? '' : String(value)) + } + } +} + export function Asset( asset: RouterManagedTag & { nonce?: string @@ -152,36 +171,19 @@ function Script({ return attrs.src } })() - const existingScript = Array.from( - document.querySelectorAll('script[src]'), - ).find((el) => (el as HTMLScriptElement).src === normSrc) - - if (existingScript) { - return + for (const el of document.querySelectorAll('script[src]')) { + if ((el as HTMLScriptElement).src === normSrc) { + return + } } const script = document.createElement('script') - for (const [key, value] of Object.entries(attrs)) { - if ( - key !== 'suppressHydrationWarning' && - value !== undefined && - value !== false - ) { - script.setAttribute( - key, - typeof value === 'boolean' ? '' : String(value), - ) - } - } + setScriptAttrs(script, attrs) document.head.appendChild(script) - return () => { - if (script.parentNode) { - script.parentNode.removeChild(script) - } - } + return () => script.remove() } if (typeof children === 'string') { @@ -189,48 +191,29 @@ function Script({ typeof attrs?.type === 'string' ? attrs.type : 'text/javascript' const nonceAttr = typeof attrs?.nonce === 'string' ? attrs.nonce : undefined - const existingScript = Array.from( - document.querySelectorAll('script:not([src])'), - ).find((el) => { - if (!(el instanceof HTMLScriptElement)) return false + for (const el of document.querySelectorAll('script:not([src])')) { + if (!(el instanceof HTMLScriptElement)) { + continue + } + const sType = el.getAttribute('type') ?? 'text/javascript' const sNonce = el.getAttribute('nonce') ?? undefined - return ( + if ( el.textContent === children && sType === typeAttr && sNonce === nonceAttr - ) - }) - - if (existingScript) { - return + ) { + return + } } const script = document.createElement('script') script.textContent = children - - if (attrs) { - for (const [key, value] of Object.entries(attrs)) { - if ( - key !== 'suppressHydrationWarning' && - value !== undefined && - value !== false - ) { - script.setAttribute( - key, - typeof value === 'boolean' ? '' : String(value), - ) - } - } - } + setScriptAttrs(script, attrs) document.head.appendChild(script) - return () => { - if (script.parentNode) { - script.parentNode.removeChild(script) - } - } + return () => script.remove() } return undefined diff --git a/packages/react-start-rsc/src/serialization.server.ts b/packages/react-start-rsc/src/serialization.server.ts index 50516ae642..466d2ab589 100644 --- a/packages/react-start-rsc/src/serialization.server.ts +++ b/packages/react-start-rsc/src/serialization.server.ts @@ -60,7 +60,13 @@ setOnClientReference( } } - if (!ctx || runtime === 'rsbuild') return + if ( + !ctx || + runtime === 'rsbuild' || + (!deps.js.length && !deps.css.length) + ) { + return + } if (!ctx.requestAssets) ctx.requestAssets = {} const seenHrefs = new Set() @@ -68,19 +74,37 @@ setOnClientReference( seenHrefs.add(typeof preload === 'string' ? preload : preload.href) } for (const stylesheet of ctx.requestAssets.css ?? []) { - seenHrefs.add(typeof stylesheet === 'string' ? stylesheet : stylesheet.href) + seenHrefs.add( + typeof stylesheet === 'string' ? stylesheet : stylesheet.href, + ) } + let nextPreloads: typeof ctx.requestAssets.preloads | undefined for (const href of deps.js) { if (seenHrefs.has(href)) continue seenHrefs.add(href) - ctx.requestAssets.preloads = [...(ctx.requestAssets.preloads ?? []), href] + if (!nextPreloads) { + nextPreloads = ctx.requestAssets.preloads + ? ctx.requestAssets.preloads.slice() + : [] + } + nextPreloads.push(href) + } + if (nextPreloads) { + ctx.requestAssets.preloads = nextPreloads } + let nextCss: typeof ctx.requestAssets.css | undefined for (const href of deps.css) { if (seenHrefs.has(href)) continue seenHrefs.add(href) - ctx.requestAssets.css = [...(ctx.requestAssets.css ?? []), href] + if (!nextCss) { + nextCss = ctx.requestAssets.css ? ctx.requestAssets.css.slice() : [] + } + nextCss.push(href) + } + if (nextCss) { + ctx.requestAssets.css = nextCss } }, ) diff --git a/packages/router-core/src/ssr/createRequestHandler.ts b/packages/router-core/src/ssr/createRequestHandler.ts index 5e000e5d2c..87ccb50c8a 100644 --- a/packages/router-core/src/ssr/createRequestHandler.ts +++ b/packages/router-core/src/ssr/createRequestHandler.ts @@ -78,9 +78,10 @@ export function createRequestHandler({ } function getRequestHeaders(opts: { router: AnyRouter }): Headers { - const matchHeaders = opts.router.stores.matches - .get() - .map((match) => match.headers) + const matchHeaders: Array = [] + for (const match of opts.router.stores.matches.get()) { + matchHeaders.push(match.headers) + } // Handle Redirects const redirect = opts.router.stores.redirect.get() diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 807c4ca42c..ce98e70b32 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -195,14 +195,25 @@ function getInlineCssHrefsForMatches( manifest: ServerManifest | undefined, matches: Array, ) { - const inlineStyles = manifest?.inlineCss?.styles - if (!inlineStyles) return [] + if (!manifest) { + return [] + } + + const inlineStyles = manifest.inlineCss?.styles + if (!inlineStyles) { + return [] + } const seen = new Set() const hrefs: Array = [] + const routes = manifest.routes for (const match of matches) { - const cssLinks = manifest?.routes[match.routeId]?.css ?? [] + const cssLinks = routes[match.routeId]?.css + if (!cssLinks) { + continue + } + for (const link of cssLinks) { const href = getStylesheetHref(link) if (seen.has(href) || inlineStyles[href] === undefined) { @@ -226,7 +237,10 @@ function getInlineCssForHrefs(manifest: ServerManifest, hrefs: Array) { if (cached !== undefined) return cached } - const css = hrefs.map((href) => styles[href]!).join('') + let css = '' + for (const href of hrefs) { + css += styles[href]! + } if (isProd) { getInlineCssCache(manifest).set(cacheKey, css) @@ -256,24 +270,46 @@ function stripInlinedStylesheetAssets( } const nextRoutes: FilteredRoutes = {} + let changed = false for (const [routeId, route] of Object.entries(routes)) { - const cssLinks = route.css?.filter( - (link) => !isInlinableStylesheet(manifest, link), - ) + const css = route.css + let cssLinks: typeof css | undefined + + if (css) { + if (css.length === 0) { + changed = true + cssLinks = [] + } + + for (let i = 0; i < css.length; i++) { + const link = css[i]! + if (!isInlinableStylesheet(manifest, link)) { + if (cssLinks) { + cssLinks.push(link) + } + continue + } + + changed = true + if (!cssLinks) { + cssLinks = css.slice(0, i) + } + } + } - const nextRoute = { ...route } if (cssLinks) { - if (cssLinks.length > 0) { - nextRoute.css = cssLinks - } else { - delete nextRoute.css + nextRoutes[routeId] = + cssLinks.length > 0 ? { ...route, css: cssLinks } : { ...route } + if (cssLinks.length === 0) { + delete nextRoutes[routeId].css } + } else { + nextRoutes[routeId] = route } - nextRoutes[routeId] = nextRoute } - return nextRoutes + return changed ? nextRoutes : routes } function hasRouteAssets(route: ManifestRoute) { @@ -288,24 +324,21 @@ function mergeRequestAssetsIntoRootRoute( rootRoute: ManifestRoute | undefined, requestAssets: ManifestRouteAssets | undefined, ): ManifestRoute { - const preloads = [ - ...(requestAssets?.preloads ?? []), - ...(rootRoute?.preloads ?? []), - ] - const scripts = [ - ...(requestAssets?.scripts ?? []), - ...(rootRoute?.scripts ?? []), - ] - const cssLinks = [ - ...(requestAssets?.css ?? []), - ...(rootRoute?.css ?? []), - ] + const preloads = requestAssets?.preloads?.length + ? [...requestAssets.preloads, ...(rootRoute?.preloads ?? [])] + : rootRoute?.preloads + const scripts = requestAssets?.scripts?.length + ? [...requestAssets.scripts, ...(rootRoute?.scripts ?? [])] + : rootRoute?.scripts + const cssLinks = requestAssets?.css?.length + ? [...requestAssets.css, ...(rootRoute?.css ?? [])] + : rootRoute?.css return { ...(rootRoute ?? {}), - ...(preloads.length ? { preloads } : {}), - ...(scripts.length ? { scripts } : {}), - ...(cssLinks.length ? { css: cssLinks } : {}), + ...(preloads?.length ? { preloads } : {}), + ...(scripts?.length ? { scripts } : {}), + ...(cssLinks?.length ? { css: cssLinks } : {}), } } diff --git a/packages/router-core/tests/ssr-server-manifest.test.ts b/packages/router-core/tests/ssr-server-manifest.test.ts index fbe938c16d..22a98e3b46 100644 --- a/packages/router-core/tests/ssr-server-manifest.test.ts +++ b/packages/router-core/tests/ssr-server-manifest.test.ts @@ -152,9 +152,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { const router = buildRouter() const manifest = buildManifest() const requestAssets: ManifestRouteAssets = { - preloads: [ - { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, - ], + preloads: [{ href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }], scripts: [ { attrs: { @@ -164,9 +162,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { children: 'console.log("request")', }, ], - css: [ - { href: '/assets/rsc-client.css', crossOrigin: 'use-credentials' }, - ], + css: [{ href: '/assets/rsc-client.css', crossOrigin: 'use-credentials' }], } attachRouterServerSsrUtils({ @@ -202,9 +198,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { const router = buildRouter() const manifest = buildManifest() const requestAssets: ManifestRouteAssets = { - preloads: [ - { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, - ], + preloads: [{ href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }], } attachRouterServerSsrUtils({ @@ -233,9 +227,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { await router.load() await router.serverSsr!.dehydrate({ requestAssets: { - preloads: [ - { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, - ], + preloads: [{ href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }], scripts: [ { attrs: { @@ -256,7 +248,9 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { const script = router.serverSsr!.takeBufferedScripts() expect(script?.children).toBeTruthy() - const dehydratedManifest = parseSerializedRouter(script!.children!).manifest! + const dehydratedManifest = parseSerializedRouter( + script!.children!, + ).manifest! expect(dehydratedManifest.routes.__root__).toMatchObject({ preloads: [ @@ -295,15 +289,15 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { await router.load() await router.serverSsr!.dehydrate({ requestAssets: { - preloads: [ - { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, - ], + preloads: [{ href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }], }, }) const script = router.serverSsr!.takeBufferedScripts() expect(script?.children).toBeTruthy() - const dehydratedManifest = parseSerializedRouter(script!.children!).manifest! + const dehydratedManifest = parseSerializedRouter( + script!.children!, + ).manifest! expect(dehydratedManifest.routes.__root__?.preloads).toEqual([ { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, @@ -356,4 +350,70 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { '/assets/index.js', ]) }) + + test('strips only inlinable stylesheet links from dehydrated manifest data', async () => { + const router = buildRouter() + const manifest: ServerManifest = { + inlineCss: { + styles: { + '/assets/root-inline.css': '.root{color:red}', + '/assets/index-inline.css': '.index{color:blue}', + }, + }, + routes: { + __root__: { + css: [ + '/assets/root-inline.css', + { + href: '/assets/root-linked.css', + crossOrigin: 'anonymous', + }, + ], + }, + '/': { + css: [ + { + href: '/assets/index-inline.css', + crossOrigin: 'use-credentials', + }, + '/assets/index-linked.css', + ], + preloads: ['/assets/index.js'], + }, + }, + } + + attachRouterServerSsrUtils({ + router, + manifest, + includeUnmatchedRouteAssets: false, + }) + + await router.load() + + expect(router.ssr!.manifest?.inlineStyle).toMatchObject({ + children: '.root{color:red}.index{color:blue}', + }) + + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + const dehydratedManifest = parseSerializedRouter( + script!.children!, + ).manifest! + + expect(dehydratedManifest.routes.__root__?.css).toEqual([ + { + href: '/assets/root-linked.css', + crossOrigin: 'anonymous', + }, + ]) + expect(dehydratedManifest.routes['/']?.css).toEqual([ + '/assets/index-linked.css', + ]) + expect(dehydratedManifest.routes['/']?.preloads).toEqual([ + '/assets/index.js', + ]) + }) }) diff --git a/packages/start-plugin-core/src/rsbuild/planning.ts b/packages/start-plugin-core/src/rsbuild/planning.ts index 764d1582b5..856ea23240 100644 --- a/packages/start-plugin-core/src/rsbuild/planning.ts +++ b/packages/start-plugin-core/src/rsbuild/planning.ts @@ -240,7 +240,7 @@ export function resolveRsbuildOutputDirectory(opts: { } function normalizeEntryPath(path: string) { - return path.replaceAll('\\', '/') + return path.includes('\\') ? path.replaceAll('\\', '/') : path } function resolveFromRoot(specifier: string, root: string): string { diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index da26d4d8db..bf716ec8d0 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -63,6 +63,7 @@ type RscPluginPair = ReturnType< type RspackConfig = Parameters[0] type RspackCompiler = Rspack.Compiler type RspackCompilationExtended = Rspack.Compilation +type RspackAsset = ReturnType[number] export function tanStackStartRsbuild( corePluginOpts: TanStackStartRsbuildPluginCoreOptions, @@ -626,18 +627,32 @@ export function tanStackStartRsbuild( .PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, }, () => { - const assetsWithPlaceholder = compilation - .getAssets() - .flatMap((asset) => { - if (!asset.name.endsWith('.js')) return [] - - const sourceStr = String(asset.source.source()) - return sourceStr.includes(manifestPlaceholderLiteral) - ? [{ asset, sourceStr }] - : [] + let assetsWithPlaceholder: + | Array<{ asset: RspackAsset; sourceStr: string }> + | undefined + + for (const asset of compilation.getAssets()) { + if (!asset.name.endsWith('.js')) { + continue + } + + const sourceStr = String(asset.source.source()) + if (!sourceStr.includes(manifestPlaceholderLiteral)) { + continue + } + + if (!assetsWithPlaceholder) { + assetsWithPlaceholder = [] + } + assetsWithPlaceholder.push({ + asset, + sourceStr, }) + } - if (assetsWithPlaceholder.length === 0) return + if (!assetsWithPlaceholder) { + return + } const clientBuild = getClientBuild() if (!clientBuild) { @@ -726,25 +741,25 @@ function rebuildModulesContaining( compilation: RspackCompilationExtended, identifierFragment: string, ): Promise { - const modulesToRebuild = Array.from(compilation.modules).filter((mod) => - mod.identifier().includes(identifierFragment), - ) + const rebuilds: Array> = [] + for (const mod of compilation.modules) { + if (!mod.identifier().includes(identifierFragment)) { + continue + } - if (modulesToRebuild.length === 0) { - return Promise.resolve() + rebuilds.push( + new Promise((resolve, reject) => { + compilation.rebuildModule(mod, (err: Error | null) => { + if (err) reject(err) + else resolve() + }) + }), + ) } - return Promise.all( - modulesToRebuild.map( - (mod) => - new Promise((resolve, reject) => { - compilation.rebuildModule(mod, (err: Error | null) => { - if (err) reject(err) - else resolve() - }) - }), - ), - ).then(() => undefined) + return rebuilds.length === 0 + ? Promise.resolve() + : Promise.all(rebuilds).then(() => undefined) } /** diff --git a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts index b015c4bf60..056a3e9bfc 100644 --- a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts @@ -566,12 +566,9 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is }, updateServerFnResolver() { - for (const environmentName of new Set([ - RSBUILD_ENVIRONMENT_NAMES.server, - ...(hasSeparateProviderEnvironment ? [opts.providerEnvName] : []), - ])) { + const updateEnvironment = (environmentName: string) => { if (!needsServerFnResolver(environmentName)) { - continue + return } writeResolverContent( @@ -579,6 +576,11 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is generateResolverContent(environmentName), ) } + + updateEnvironment(RSBUILD_ENVIRONMENT_NAMES.server) + if (hasSeparateProviderEnvironment) { + updateEnvironment(opts.providerEnvName) + } }, tryUpdateServerFnResolver(content: string) { diff --git a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts index e93d9cca3a..a07237fe03 100644 --- a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts +++ b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts @@ -34,9 +34,7 @@ type RouteTreeRoute = { type RouteTreeRoutes = Record -type AdditionalRouteManifestEntry = - | ManifestCssLink - | ManifestScript +type AdditionalRouteManifestEntry = ManifestCssLink | ManifestScript interface ScannedClientChunks { entryChunk: NormalizedClientChunk @@ -165,9 +163,7 @@ function normalizeAttrs(attrs: Record | undefined) { function mergeRouteChunkData(options: { route: RouteTreeRoute chunk: NormalizedClientChunk - getChunkCssAssets: ( - chunk: NormalizedClientChunk, - ) => Array + getChunkCssAssets: (chunk: NormalizedClientChunk) => Array getChunkPreloads: (chunk: NormalizedClientChunk) => Array }) { const stylesheets = options.getChunkCssAssets(options.chunk) @@ -238,14 +234,10 @@ export function buildStartManifest(options: { additionalRouteAssets: options.additionalRouteAssets, }) - dedupeNestedRouteManifestEntries( - rootRouteId, - routes[rootRouteId]!, - routes, - ) + dedupeNestedRouteManifestEntries(rootRouteId, routes[rootRouteId]!, routes) // Prune routes with no manifest data - for (const routeId of Object.keys(routes)) { + for (const routeId in routes) { const route = routes[routeId]! const hasScripts = route.scripts && route.scripts.length > 0 const hasCssLinks = route.css && route.css.length > 0 @@ -371,10 +363,7 @@ export function createChunkCssAssetCollector(options: { chunksByFileName: ReadonlyMap getStylesheetLink: (cssFile: string) => ManifestCssLink }) { - const linksByChunk = new Map< - NormalizedClientChunk, - Array - >() + const linksByChunk = new Map>() const stateByChunk = new Map() const appendAsset = ( @@ -456,7 +445,7 @@ function buildInlineCssManifestData(options: { const { getAssetPath } = createManifestAssetResolvers(options.basePath) const styles: Record = {} - const templates: Record = {} + let templates: Record | undefined const missingHrefs = new Set(stylesheetHrefs) for (const [cssFile, css] of options.cssContentByFileName) { @@ -473,6 +462,7 @@ function buildInlineCssManifestData(options: { styles[cssHref] = result.css if (result.template) { + templates ||= {} templates[cssHref] = result.template } missingHrefs.delete(cssHref) @@ -488,7 +478,7 @@ function buildInlineCssManifestData(options: { return { styles, - ...(Object.keys(templates).length ? { templates } : {}), + ...(templates ? { templates } : {}), } } @@ -599,9 +589,7 @@ function mergeReachableHydrationChunkData(options: { route: RouteTreeRoute chunk: NormalizedClientChunk chunksByFileName: ReadonlyMap - getChunkCssAssets: ( - chunk: NormalizedClientChunk, - ) => Array + getChunkCssAssets: (chunk: NormalizedClientChunk) => Array }) { const visitedStaticChunks = new Set() const mergedHydrationChunks = new Set() diff --git a/packages/start-server-core/src/early-hints.ts b/packages/start-server-core/src/early-hints.ts index 4ac7a4630a..f46cf5f587 100644 --- a/packages/start-server-core/src/early-hints.ts +++ b/packages/start-server-core/src/early-hints.ts @@ -196,11 +196,11 @@ export function collectStaticHintsFromManifest( } for (const link of routeManifest.css ?? []) { - const resolvedLink = resolveManifestCssLink(link) const stylesheetHref = getStylesheetHref(link) if (manifest.inlineCss?.styles[stylesheetHref] !== undefined) { continue } + const resolvedLink = resolveManifestCssLink(link) const hint: EarlyHint = { href: stylesheetHref, diff --git a/packages/start-server-core/src/router-manifest.ts b/packages/start-server-core/src/router-manifest.ts index 3b7a792aa6..451decd6a8 100644 --- a/packages/start-server-core/src/router-manifest.ts +++ b/packages/start-server-core/src/router-manifest.ts @@ -3,11 +3,7 @@ import { buildDevStylesUrl, rootRouteId, } from '@tanstack/router-core' -import type { - AnyRoute, - ManifestAssetLink, - ServerManifestRoute, -} from '@tanstack/router-core' +import type { AnyRoute, ServerManifestRoute } from '@tanstack/router-core' import type { StartManifestWithClientEntry } from './transformAssetUrls' // Pre-computed constant for dev styles URL basepath. @@ -79,37 +75,32 @@ export async function getStartManifest( } } + const manifestRoutes: Record = {} + + for (const k in routes) { + const v = routes[k]! + const result = {} as ServerManifestRoute + + if (v.preloads && v.preloads.length > 0) { + result.preloads = v.preloads + } + if (v.scripts && v.scripts.length > 0) { + result.scripts = v.scripts + } + if (v.css?.length) { + result.css = v.css + } + if (result.preloads || result.scripts || result.css) { + manifestRoutes[k] = result + } + } + const manifest = { ...(startManifest.scriptFormat ? { scriptFormat: startManifest.scriptFormat } : {}), ...(startManifest.inlineCss ? { inlineCss: startManifest.inlineCss } : {}), - routes: Object.fromEntries( - Object.entries(routes).flatMap(([k, v]) => { - const result = {} as { - preloads?: Array - scripts?: ServerManifestRoute['scripts'] - css?: ServerManifestRoute['css'] - } - let hasData = false - if (v.preloads && v.preloads.length > 0) { - result['preloads'] = v.preloads - hasData = true - } - if (v.scripts && v.scripts.length > 0) { - result['scripts'] = v.scripts - hasData = true - } - if (v.css?.length) { - result['css'] = v.css - hasData = true - } - if (!hasData) { - return [] - } - return [[k, result]] - }), - ), + routes: manifestRoutes, } return { diff --git a/packages/start-server-core/src/transformAssetUrls.ts b/packages/start-server-core/src/transformAssetUrls.ts index a4d8ebfc13..c2b03ad9e3 100644 --- a/packages/start-server-core/src/transformAssetUrls.ts +++ b/packages/start-server-core/src/transformAssetUrls.ts @@ -382,10 +382,14 @@ function appendUniqueManifestAssetLink( target: Array | undefined, link: ManifestAssetLink, ) { - const href = resolveManifestAssetLink(link).href + const href = typeof link === 'string' ? link : link.href - if (target?.some((item) => resolveManifestAssetLink(item).href === href)) { - return target + if (target) { + for (const item of target) { + if ((typeof item === 'string' ? item : item.href) === href) { + return target + } + } } return [...(target ?? []), link] @@ -461,7 +465,7 @@ export async function transformManifestAssets( addClientEntryToManifest(manifest, source.clientEntry) for (const route of Object.values(manifest.routes)) { - if (route.preloads) { + if (route.preloads?.length) { route.preloads = await Promise.all( route.preloads.map(async (link) => { const resolved = resolveManifestAssetLink(link) @@ -475,7 +479,7 @@ export async function transformManifestAssets( ) } - if (route.css && !manifest.inlineCss) { + if (route.css?.length && !manifest.inlineCss) { route.css = await Promise.all( route.css.map(async (link) => { const resolved = resolveManifestCssLink(link) @@ -494,7 +498,7 @@ export async function transformManifestAssets( ) } - if (route.scripts) { + if (route.scripts?.length) { for (const script of route.scripts) { const src = script.attrs?.src if (typeof src !== 'string') { diff --git a/packages/start-server-core/tests/early-hints.test.ts b/packages/start-server-core/tests/early-hints.test.ts index 127bcbb636..d56824a828 100644 --- a/packages/start-server-core/tests/early-hints.test.ts +++ b/packages/start-server-core/tests/early-hints.test.ts @@ -124,6 +124,35 @@ describe('early hints', () => { ]) }) + it('skips object-form static stylesheet hints when inline CSS is enabled', () => { + const manifest: ServerManifest = { + inlineCss: { + styles: { + '/assets/posts.css': '.posts{}', + }, + }, + routes: { + '/posts': { + preloads: ['/assets/posts.js'], + css: [ + { + href: '/assets/posts.css', + crossOrigin: 'use-credentials', + }, + ], + }, + }, + } + + expect( + collectStaticHintsFromManifest(manifest, [ + { id: '/posts' }, + ] as Array), + ).toEqual([ + { href: '/assets/posts.js', rel: 'modulepreload', as: 'script' }, + ]) + }) + it('collects static route JS hints as preload script for iife manifests', () => { const manifest: ServerManifest = { scriptFormat: 'iife', diff --git a/packages/start-server-core/tests/finalManifest.test.ts b/packages/start-server-core/tests/finalManifest.test.ts index 1ba9d18c89..7a6882694a 100644 --- a/packages/start-server-core/tests/finalManifest.test.ts +++ b/packages/start-server-core/tests/finalManifest.test.ts @@ -124,7 +124,7 @@ describe('final manifest resolver', () => { await expect(warmupPromise).resolves.toBe(requestManifest) expect(requestManifest.inlineCss).toBeUndefined() - expect(transformAssets).toHaveBeenCalledTimes(2) + expect(transformAssets).toHaveBeenCalledTimes(1) }) it('does not warm up when the inline CSS default is request-dependent', () => { diff --git a/packages/start-server-core/tests/transformAssets.test.ts b/packages/start-server-core/tests/transformAssets.test.ts index c943588d66..cf8f8eb541 100644 --- a/packages/start-server-core/tests/transformAssets.test.ts +++ b/packages/start-server-core/tests/transformAssets.test.ts @@ -152,6 +152,40 @@ describe('transformAssets', () => { ]) }) + it('does not duplicate an object-form client entry preload', async () => { + const transformFn = vi.fn(({ url }) => ({ + href: `https://cdn.example.com${url}`, + crossOrigin: 'anonymous' as const, + })) + + const manifest = await transformManifestAssets( + { + manifest: { + routes: { + __root__: { + preloads: [ + { href: '/assets/entry.js', crossOrigin: 'use-credentials' }, + ], + }, + }, + }, + clientEntry: '/assets/entry.js', + }, + transformFn, + ) + + expect(manifest.routes.__root__?.preloads).toEqual([ + { + href: 'https://cdn.example.com/assets/entry.js', + crossOrigin: 'anonymous', + }, + ]) + expect(transformFn).toHaveBeenCalledTimes(1) + expect(transformFn.mock.calls).toEqual([ + [{ kind: 'script', url: '/assets/entry.js' }], + ]) + }) + it('reuses the transformed client entry URL for matching preload and script tags', async () => { let signature = 0 const transformFn = vi.fn(({ url }) => ({ @@ -258,10 +292,10 @@ describe('transformAssets', () => { const source: StartManifestWithClientEntry = { manifest: { routes: { - __root__: { - preloads: ['/assets/app.js'], - css: ['/assets/app.css'], - }, + __root__: { + preloads: ['/assets/app.js'], + css: ['/assets/app.css'], + }, }, }, clientEntry: '/assets/entry.js', diff --git a/packages/vue-router/src/HeadContent.tsx b/packages/vue-router/src/HeadContent.tsx index b69ac79b72..6d89679c8a 100644 --- a/packages/vue-router/src/HeadContent.tsx +++ b/packages/vue-router/src/HeadContent.tsx @@ -34,7 +34,7 @@ function attrsMatch(attrs: Record, element: Element) { } } - return expectedAttrCount === element.getAttributeNames().length + return expectedAttrCount === element.attributes.length } function reconcileHydratedHead( @@ -61,10 +61,7 @@ function reconcileHydratedHead( } for (const element of hydratedLinks) { - if ( - !matchedHeadElements.has(element) && - attrsMatch(attrs!, element) - ) { + if (!matchedHeadElements.has(element) && attrsMatch(attrs!, element)) { matchedHeadElements.add(element) const key = JSON.stringify(tag) ;(preservedHeadTagElements[key] ||= []).push(element) diff --git a/packages/vue-router/src/Scripts.tsx b/packages/vue-router/src/Scripts.tsx index 3b2f4a5ff7..32c9eef8b5 100644 --- a/packages/vue-router/src/Scripts.tsx +++ b/packages/vue-router/src/Scripts.tsx @@ -31,13 +31,11 @@ export const Scripts = Vue.defineComponent({ const routeManifest = manifest.routes[match.routeId] routeManifest?.scripts?.forEach((asset) => { - const scriptAsset = { + assetScripts.push({ tag: 'script', attrs: { ...asset.attrs, nonce }, children: asset.children, - } satisfies RouterManagedTag - - assetScripts.push(scriptAsset) + }) }) }) diff --git a/packages/vue-router/tests/Scripts.test.tsx b/packages/vue-router/tests/Scripts.test.tsx index 75f0aa4953..5cd2eacd4f 100644 --- a/packages/vue-router/tests/Scripts.test.tsx +++ b/packages/vue-router/tests/Scripts.test.tsx @@ -205,96 +205,91 @@ describe('ssr HeadContent', () => { ).toBe('anonymous') }) - test( - 'does not duplicate SSR head links and cleans up preloads on unmount', - async () => { - const history = createTestBrowserHistory() - - const ssrStylesheet = document.createElement('link') - ssrStylesheet.setAttribute('rel', 'stylesheet') - ssrStylesheet.setAttribute('href', '/main.css') - - const ssrPreload = document.createElement('link') - ssrPreload.setAttribute('rel', 'modulepreload') - ssrPreload.setAttribute('href', '/main.js') - - document.head.append(ssrStylesheet, ssrPreload) - - try { - const rootRoute = createRootRoute({ - component: () => ( - <> - - - - - - ), - }) + test('does not duplicate SSR head links and cleans up preloads on unmount', async () => { + const history = createTestBrowserHistory() - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () =>
Index
, - }) + const ssrStylesheet = document.createElement('link') + ssrStylesheet.setAttribute('rel', 'stylesheet') + ssrStylesheet.setAttribute('href', '/main.css') - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute]), - }) + const ssrPreload = document.createElement('link') + ssrPreload.setAttribute('rel', 'modulepreload') + ssrPreload.setAttribute('href', '/main.js') - router.ssr = { - manifest: createTestManifest(rootRoute.id), - } + document.head.append(ssrStylesheet, ssrPreload) + + try { + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) - await router.load() + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } - const { unmount } = render() + await router.load() - await waitFor(() => { - expect( - document.head.querySelectorAll( - 'link[rel="stylesheet"][href="/main.css"]', - ), - ).toHaveLength(1) - expect( - document.head.querySelectorAll( - 'link[rel="modulepreload"][href="/main.js"]', - ), - ).toHaveLength(1) - }) + const { unmount } = render() + await waitFor(() => { expect( - document.head.querySelector( + document.head.querySelectorAll( 'link[rel="stylesheet"][href="/main.css"]', ), - ).toBe(ssrStylesheet) + ).toHaveLength(1) expect( - document.head.querySelector( + document.head.querySelectorAll( 'link[rel="modulepreload"][href="/main.js"]', ), - ).toBe(ssrPreload) - - unmount() - - await waitFor(() => { - expect( - document.head.querySelectorAll( - 'link[rel="stylesheet"][href="/main.css"]', - ), - ).toHaveLength(1) - expect( - document.head.querySelectorAll( - 'link[rel="modulepreload"][href="/main.js"]', - ), - ).toHaveLength(0) - }) - } finally { - ssrStylesheet.remove() - ssrPreload.remove() - } - }, - ) + ).toHaveLength(1) + }) + + expect( + document.head.querySelector('link[rel="stylesheet"][href="/main.css"]'), + ).toBe(ssrStylesheet) + expect( + document.head.querySelector( + 'link[rel="modulepreload"][href="/main.js"]', + ), + ).toBe(ssrPreload) + + unmount() + + await waitFor(() => { + expect( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/main.css"]', + ), + ).toHaveLength(1) + expect( + document.head.querySelectorAll( + 'link[rel="modulepreload"][href="/main.js"]', + ), + ).toHaveLength(0) + }) + } finally { + ssrStylesheet.remove() + ssrPreload.remove() + } + }) test('removes preserved SSR-rendered route preload links after navigation', async () => { const history = createTestBrowserHistory() @@ -519,6 +514,74 @@ describe('ssr HeadContent', () => { } }) + test('does not preserve an SSR-rendered head link with extra attrs', async () => { + const history = createTestBrowserHistory() + + const ssrStylesheet = document.createElement('link') + ssrStylesheet.setAttribute('rel', 'stylesheet') + ssrStylesheet.setAttribute('href', '/main.css') + ssrStylesheet.setAttribute('data-stale', '1') + + document.head.append(ssrStylesheet) + + try { + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } + + await router.load() + + render() + + await waitFor(() => { + expect( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/main.css"]', + ), + ).toHaveLength(2) + }) + + const links = Array.from( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/main.css"]', + ), + ) + expect(links).toContain(ssrStylesheet) + expect( + links.some( + (element) => + element !== ssrStylesheet && !element.hasAttribute('data-stale'), + ), + ).toBe(true) + } finally { + document.head + .querySelectorAll('link[rel="stylesheet"][href="/main.css"]') + .forEach((element) => element.remove()) + } + }) + test('renders runtime manifest inlineStyle', async () => { const history = createTestBrowserHistory() From 37fa32ab9766db27540957059a1e8240a4e956a8 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 24 May 2026 01:46:03 +0200 Subject: [PATCH 04/10] fix(start): replay rsbuild server function metadata from rspack cache --- .changeset/slow-badgers-remember.md | 10 + e2e/react-start/server-functions/package.json | 3 +- .../playwright.rsbuild-cache.config.ts | 15 + .../server-functions/rsbuild.config.ts | 3 + .../tests/rsbuild-cache.spec.ts | 390 ++++++++++++++++++ .../src/rsbuild/COMPILER_ARCHITECTURE.md | 199 +++++++++ .../start-plugin-core/src/rsbuild/plugin.ts | 82 +++- .../src/rsbuild/start-compiler-host.ts | 247 +++++++++-- .../rsbuild/start-compiler-metadata-loader.ts | 22 + .../src/rsbuild/start-compiler-metadata.ts | 23 ++ packages/start-plugin-core/vite.config.ts | 1 + 11 files changed, 945 insertions(+), 50 deletions(-) create mode 100644 .changeset/slow-badgers-remember.md create mode 100644 e2e/react-start/server-functions/playwright.rsbuild-cache.config.ts create mode 100644 e2e/react-start/server-functions/tests/rsbuild-cache.spec.ts create mode 100644 packages/start-plugin-core/src/rsbuild/COMPILER_ARCHITECTURE.md create mode 100644 packages/start-plugin-core/src/rsbuild/start-compiler-metadata-loader.ts create mode 100644 packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts diff --git a/.changeset/slow-badgers-remember.md b/.changeset/slow-badgers-remember.md new file mode 100644 index 0000000000..4a6fb197df --- /dev/null +++ b/.changeset/slow-badgers-remember.md @@ -0,0 +1,10 @@ +--- +'@tanstack/start-plugin-core': patch +'@tanstack/react-start': patch +'@tanstack/solid-start': patch +'@tanstack/vue-start': patch +--- + +Fix Rsbuild server function metadata replay when Rspack restores modules from its persistent cache. + +Server function metadata is now stored on Rspack module build info and replayed from cached modules before resolver modules are rebuilt, preventing warm restarts from losing server function registrations. diff --git a/e2e/react-start/server-functions/package.json b/e2e/react-start/server-functions/package.json index 2f2c698e2c..c7f8876f63 100644 --- a/e2e/react-start/server-functions/package.json +++ b/e2e/react-start/server-functions/package.json @@ -13,7 +13,8 @@ "build:rsbuild": "rsbuild build && tsc --noEmit", "preview": "vite preview", "start": "node server.js", - "test:e2e": "rm -rf port*.txt && pnpm build && playwright test --project=chromium" + "test:e2e": "rm -rf port*.txt && pnpm build && playwright test --project=chromium && pnpm run test:e2e:rsbuild-cache", + "test:e2e:rsbuild-cache": "playwright test -c playwright.rsbuild-cache.config.ts --project=chromium" }, "dependencies": { "@tanstack/react-query": "^5.90.0", diff --git a/e2e/react-start/server-functions/playwright.rsbuild-cache.config.ts b/e2e/react-start/server-functions/playwright.rsbuild-cache.config.ts new file mode 100644 index 0000000000..efa7357cab --- /dev/null +++ b/e2e/react-start/server-functions/playwright.rsbuild-cache.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + testMatch: /rsbuild-cache\.spec\.ts/, + timeout: 120_000, + workers: 1, + reporter: [['line']], + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/server-functions/rsbuild.config.ts b/e2e/react-start/server-functions/rsbuild.config.ts index 23072b6c1b..e5efd7624b 100644 --- a/e2e/react-start/server-functions/rsbuild.config.ts +++ b/e2e/react-start/server-functions/rsbuild.config.ts @@ -13,4 +13,7 @@ export default defineConfig({ root: outDir, }, }, + performance: { + buildCache: process.env.E2E_RSBUILD_BUILD_CACHE !== 'false', + }, }) diff --git a/e2e/react-start/server-functions/tests/rsbuild-cache.spec.ts b/e2e/react-start/server-functions/tests/rsbuild-cache.spec.ts new file mode 100644 index 0000000000..32a2f406e6 --- /dev/null +++ b/e2e/react-start/server-functions/tests/rsbuild-cache.spec.ts @@ -0,0 +1,390 @@ +import { spawn } from 'node:child_process' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { expect, test } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from '../package.json' with { type: 'json' } +import type { ChildProcess } from 'node:child_process' +import type { Page, Request } from '@playwright/test' + +const appDir = resolve(import.meta.dirname, '..') +const cacheDir = resolve(appDir, 'node_modules/.cache/rspack') +const consistentRoutePath = resolve(appDir, 'src/routes/consistent.tsx') +const noServerFunctionsConsistentRouteSource = `import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/consistent')({ + component: ConsistentServerFnCalls, +}) + +function ConsistentServerFnCalls() { + return ( +
+ Server functions removed by watch test +
+ ) +} +` + +interface ServerFnRequest { + method: string + url: string +} + +interface DevServer { + child: ChildProcess + logs: Array +} + +function startRsbuildDevServer( + port: number, + buildCacheEnabled = true, +): DevServer { + const logs: Array = [] + const child = spawn( + 'pnpm', + ['exec', 'rsbuild', 'dev', '--port', String(port)], + { + cwd: appDir, + detached: true, + env: { + ...process.env, + PORT: String(port), + VITE_SERVER_PORT: String(port), + VITE_NODE_ENV: 'test', + E2E_RSBUILD_BUILD_CACHE: buildCacheEnabled ? 'true' : 'false', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + + child.stdout?.on('data', (chunk: Buffer) => logs.push(chunk.toString())) + child.stderr?.on('data', (chunk: Buffer) => logs.push(chunk.toString())) + + return { child, logs } +} + +function killDevServer(devServer: DevServer, signal: NodeJS.Signals) { + const pid = devServer.child.pid + + try { + if (pid) { + process.kill(-pid, signal) + return + } + } catch { + // Fall back to killing the direct child below. + } + + devServer.child.kill(signal) +} + +async function waitForHttpOk(devServer: DevServer, url: string): Promise { + const start = Date.now() + + while (Date.now() - start < 60_000) { + if (devServer.child.exitCode !== null) { + throw new Error( + `Rsbuild dev server exited before ${url} was ready.\n${devServer.logs.join('')}`, + ) + } + + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(1000), + }) + + if (response.status >= 200 && response.status < 400) { + return + } + } catch { + // Wait until the dev server starts accepting requests. + } + + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + throw new Error( + `Timed out waiting for Rsbuild dev server at ${url}.\n${devServer.logs.join('')}`, + ) +} + +async function stopDevServer(devServer: DevServer): Promise { + if (devServer.child.exitCode !== null || devServer.child.killed) { + return + } + + await new Promise((resolve) => { + let settled = false + let killTimer: ReturnType | undefined + let doneTimer: ReturnType | undefined + + const done = () => { + if (settled) { + return + } + settled = true + if (killTimer) { + clearTimeout(killTimer) + } + if (doneTimer) { + clearTimeout(doneTimer) + } + resolve() + } + + devServer.child.once('exit', done) + devServer.child.once('error', done) + + try { + killDevServer(devServer, 'SIGTERM') + } catch { + done() + return + } + + killTimer = setTimeout(() => { + try { + killDevServer(devServer, 'SIGKILL') + } catch { + // The process may have already exited. + } + + doneTimer = setTimeout(done, 500) + }, 3000) + }) +} + +async function waitForRetry( + devServer: DevServer, + description: string, + action: () => Promise, +): Promise { + const start = Date.now() + let lastError: unknown + + while (Date.now() - start < 30_000) { + if (devServer.child.exitCode !== null) { + throw new Error( + `Rsbuild dev server exited while waiting for ${description}.\n${devServer.logs.join('')}`, + ) + } + + try { + return await action() + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + + throw new Error( + `Timed out waiting for ${description}.\n${lastError instanceof Error ? lastError.stack || lastError.message : String(lastError)}\n\nRsbuild logs:\n${devServer.logs.join('')}`, + ) +} + +async function captureServerFnRequests( + page: Page, + action: () => Promise, +): Promise> { + const requests: Array = [] + const listener = (request: Request) => { + if (request.url().includes('/_serverFn/')) { + requests.push({ + method: request.method(), + url: request.url(), + }) + } + } + + page.on('request', listener) + try { + await action() + } finally { + page.off('request', listener) + } + + return requests +} + +async function exerciseServerFunctions( + page: Page, + baseURL: string, + expectedUsername = 'TEST', +) { + const response = await page.goto(`${baseURL}/consistent`, { + waitUntil: 'networkidle', + }) + expect(response?.ok()).toBe(true) + + const expectedLocator = page.getByTestId( + 'expected-consistent-server-fns-result', + ) + await expect(expectedLocator).toContainText( + JSON.stringify({ payload: { username: expectedUsername } }), + ) + const expected = (await expectedLocator.textContent()) || '' + + await page.getByTestId('test-consistent-server-fn-calls-btn').click() + await page.waitForLoadState('networkidle') + + await expect(page.getByTestId('cons_serverGetFn1-response')).toContainText( + expected, + ) + await expect(page.getByTestId('cons_getFn1-response')).toContainText(expected) + await expect(page.getByTestId('cons_serverPostFn1-response')).toContainText( + expected, + ) + await expect(page.getByTestId('cons_postFn1-response')).toContainText( + expected, + ) +} + +async function exerciseServerFunctionsWithLogs( + page: Page, + baseURL: string, + devServer: DevServer, + expectedUsername = 'TEST', +) { + try { + await exerciseServerFunctions(page, baseURL, expectedUsername) + } catch (error) { + throw new Error( + `${error instanceof Error ? error.stack || error.message : String(error)}\n\nRsbuild logs:\n${devServer.logs.join('')}`, + ) + } +} + +async function waitForStaticConsistentRoute( + page: Page, + baseURL: string, + devServer: DevServer, +) { + await waitForRetry(devServer, 'static consistent route update', async () => { + const response = await page.goto( + `${baseURL}/consistent?watch=${Date.now()}`, + { + waitUntil: 'networkidle', + }, + ) + expect(response?.ok()).toBe(true) + await expect(page.getByTestId('watch-no-server-functions')).toContainText( + 'Server functions removed by watch test', + { timeout: 1000 }, + ) + }) +} + +test('rsbuild build cache warm restart keeps server function registry', async ({ + page, +}) => { + test.setTimeout(120_000) + + rmSync(cacheDir, { recursive: true, force: true }) + + const port = await getTestServerPort(`${packageJson.name}-rsbuild-cache`) + const baseURL = `http://localhost:${port}` + let devServer = startRsbuildDevServer(port) + + try { + await waitForHttpOk(devServer, baseURL) + await exerciseServerFunctionsWithLogs(page, baseURL, devServer) + } finally { + await stopDevServer(devServer) + } + + expect(existsSync(cacheDir)).toBe(true) + + devServer = startRsbuildDevServer(port) + + try { + await waitForHttpOk(devServer, baseURL) + await exerciseServerFunctionsWithLogs(page, baseURL, devServer) + + expect(devServer.logs.join('')).not.toContain( + 'Server function info not found', + ) + } finally { + await stopDevServer(devServer) + } +}) + +test('rsbuild without build cache keeps server function registry', async ({ + page, +}) => { + test.setTimeout(120_000) + + rmSync(cacheDir, { recursive: true, force: true }) + + const port = await getTestServerPort(`${packageJson.name}-rsbuild-no-cache`) + const baseURL = `http://localhost:${port}` + const devServer = startRsbuildDevServer(port, false) + + try { + await waitForHttpOk(devServer, baseURL) + await exerciseServerFunctionsWithLogs(page, baseURL, devServer) + + expect(devServer.logs.join('')).not.toContain( + 'Server function info not found', + ) + } finally { + await stopDevServer(devServer) + } +}) + +test('rsbuild watch updates and removes server function metadata', async ({ + page, +}) => { + test.setTimeout(120_000) + + const originalSource = readFileSync(consistentRoutePath, 'utf8') + const updatedSource = originalSource.replaceAll("'TEST'", "'WATCH'") + rmSync(cacheDir, { recursive: true, force: true }) + + const port = await getTestServerPort(`${packageJson.name}-rsbuild-watch`) + const baseURL = `http://localhost:${port}` + const devServer = startRsbuildDevServer(port) + + try { + await waitForHttpOk(devServer, baseURL) + await exerciseServerFunctionsWithLogs(page, baseURL, devServer) + + writeFileSync(consistentRoutePath, updatedSource) + const serverFnRequests = await waitForRetry( + devServer, + 'updated server function metadata', + async () => { + return await captureServerFnRequests(page, async () => { + await exerciseServerFunctionsWithLogs( + page, + baseURL, + devServer, + 'WATCH', + ) + }) + }, + ) + const staleGetRequest = serverFnRequests.find( + (request) => request.method === 'GET', + ) + expect(staleGetRequest).toBeDefined() + + writeFileSync(consistentRoutePath, noServerFunctionsConsistentRouteSource) + await waitForStaticConsistentRoute(page, baseURL, devServer) + + const staleResult = await page.evaluate(async (url) => { + const response = await fetch(url) + return { + ok: response.ok, + status: response.status, + text: await response.text(), + } + }, staleGetRequest!.url) + + expect(staleResult.ok).toBe(false) + expect(staleResult.status).toBeGreaterThanOrEqual(400) + expect(staleResult.text).not.toContain('WATCH') + } finally { + writeFileSync(consistentRoutePath, originalSource) + await stopDevServer(devServer) + } +}) diff --git a/packages/start-plugin-core/src/rsbuild/COMPILER_ARCHITECTURE.md b/packages/start-plugin-core/src/rsbuild/COMPILER_ARCHITECTURE.md new file mode 100644 index 0000000000..a97c500b0d --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/COMPILER_ARCHITECTURE.md @@ -0,0 +1,199 @@ +# Rsbuild Compiler Architecture + +This document explains the TanStack Start compiler integration for Rsbuild and Rspack. It focuses on server-function discovery, Rspack cache interaction, and the resolver virtual module. + +## Goals + +The Rsbuild integration has to do three things at the same time: + +- Compile Start-specific source patterns such as server functions, isomorphic functions, middleware, and compiler virtual modules. +- Keep server-function metadata available when Rspack restores modules from its persistent cache and skips transforms. +- Generate the `#tanstack-start-server-fn-resolver` virtual module after the server-function registry is complete for the current compilation. + +The important point is that server-function discovery is a side effect of compilation. If Rspack restores a transformed module from persistent cache, the transform side effect does not run. We therefore store the discovered metadata on the Rspack module itself and replay it from `module.buildInfo`. + +## Main Files + +- `plugin.ts` wires the Rsbuild plugin phases, virtual modules, RSC hooks, compiler ordering, and resolver rebuild hooks. +- `start-compiler-host.ts` registers the Start compiler transforms, captures server-function metadata, writes metadata to Rspack `buildInfo`, and replays cached metadata. +- `start-compiler-metadata.ts` defines the string keys and types used by the metadata loader. +- `start-compiler-metadata-loader.ts` is the real Rspack loader that writes server-function metadata to the current module's `buildInfo`. +- `virtual-modules.ts` owns the resolver virtual module and writes updated virtual module content per Rsbuild environment. +- `../start-compiler/handleCreateServerFn.ts` discovers server functions while compiling caller modules. +- `../start-compiler/server-fn-resolver-module.ts` generates the resolver module from the current server-function registry. + +## Environments + +Start registers compiler work for each Rsbuild environment that can affect server-function metadata: + +- `client` discovers functions referenced from browser code and marks them client-accessible. +- `ssr` discovers functions reachable from the server-rendered route graph. +- A separate provider environment is included when server-function providers are not compiled in `ssr` and RSC is not enabled. + +The shared registry is `serverFnsById`. It is passed to `virtual-modules.ts`, which uses it to generate resolver module content. + +## Cold Transform Path + +`registerStartCompilerTransforms()` registers an `api.transform({ order: 'pre' })` transform for each Start compiler environment. + +When a matching module is compiled normally: + +1. Rsbuild runs the Start transform in the pre-loader phase. +2. The transform checks compiler virtual modules first. +3. It runs cheap code filters to skip files that cannot contain Start compiler patterns. +4. It lazily creates one `StartCompiler` per environment. +5. It detects which compiler features are present in the current code. +6. It serializes access to the per-environment `StartCompiler` through `runCompilerTask()`. +7. It calls `compiler.compile({ id, code, detectedKinds })`. +8. During compile, `handleCreateServerFn()` reports discovered server functions through `onServerFnsById`. +9. The transform stores the discovered server functions for this module in the environment's pending metadata map. + +The module ID used for transform metadata is `ctx.resource`. The metadata loader later reads `this.resource`. Using the same Rspack resource string keeps the handoff stable across resource queries. + +## Server-Function Discovery + +Server functions are discovered in caller modules, not in provider modules. In `handleCreateServerFn()`, each discovered function records: + +- `functionName`, the generated handler export name. +- `functionId`, the stable server-function ID. +- `filename`, the caller module ID. +- `extractedFilename`, the provider module ID with the server-function split query. +- `isClientReferenced`, whether client-origin calls are allowed. + +`onServerFnsById` merges discoveries into the shared `serverFnsById` registry. While a compile task is active, it also merges the same discoveries into that module's `activeServerFnMetadata` object. After compile finishes, that object becomes the metadata payload for the module. + +Per-environment compiler tasks are serialized because `StartCompiler` owns mutable module caches and because `activeServerFnMetadata` must only describe the module currently being compiled in that environment. + +## Loading And Resolving Modules + +The compiler sometimes needs to read or resolve imported modules while compiling a source file. + +`start-compiler-host.ts` uses two native Rsbuild/Rspack surfaces for this: + +- `ctx.resolve()` from the active Rsbuild transform context resolves module IDs. +- `compiler.inputFileSystem` reads source through Rspack's input filesystem. + +An `AsyncLocalStorage` value binds `loadModule` and `resolveId` back to the active transform context. That lets the compiler add dependencies to the current Rspack module without reaching into Rspack private resolver internals. + +## Why A Real Loader Is Used + +Rsbuild `api.transform()` is implemented as a loader, but its callback only exposes the transform context. It does not expose the current `NormalModule` or `module.buildInfo`. + +Rspack persists custom `module.buildInfo` fields. To write metadata there, Start installs a small real loader after the transform: + +1. `start-compiler-host.ts` adds a rule with `enforce: 'post'` for Start transformable modules. +2. The rule points to the emitted `start-compiler-metadata-loader.js` file. +3. The loader receives the environment's pending metadata map through loader options. +4. A public `NormalModule.getCompilationHooks(compilation).loader` hook adds a setter to the loader context. +5. The loader reads metadata for `this.resource` and calls the setter. +6. The setter writes to `module.buildInfo['tanstack.start.serverFns']`. + +Only string-keyed, JSON-serializable `buildInfo` fields are used. Rspack persists those fields with the module cache. Symbol keys and private cache internals are intentionally avoided. + +## Cache-Enabled And Cache-Disabled Behavior + +The metadata loader is installed regardless of `performance.buildCache`. + +When persistent cache is enabled, Rspack may later restore a module without rerunning the Start transform or the metadata loader. The `buildInfo` payload is then replayed from the cached module. + +When persistent cache is disabled, the same loader path still runs during normal compilation and writes in-memory `buildInfo`. Keeping one path avoids conditional behavior and keeps cold, watch, cache-disabled, and cache-enabled builds aligned. The overhead is one small post loader for modules that already match the Start transform test. + +The cache-specific part is not the loader itself. The cache-specific part is that Rspack can persist and later restore the `buildInfo` field. + +## Stale Metadata Clearing + +If a module previously contained server functions and then no longer produces metadata, the metadata loader writes an empty payload: + +```ts +{ version: 1, serverFnsById: {} } +``` + +This prevents stale server-function entries from surviving on a cached module's `buildInfo` after a file edit removes or renames server functions. + +## Warm Cache Replay + +Each relevant compiler installs a `finishMake` hook at stage `-20`. + +At that point Rspack has built or restored the module graph for the environment, and module `buildInfo` is available. The hook: + +1. Iterates over `compilation.modules`. +2. Reads `module.buildInfo['tanstack.start.serverFns']`. +3. Validates the payload with the versioned schema. +4. Merges valid module metadata into an environment snapshot. +5. Stores the snapshot in `serverFnsByEnvironment`. +6. Rebuilds the shared `serverFnsById` registry from all environment snapshots. +7. Notifies virtual modules that resolver content may have changed. + +The shared registry is rebuilt from snapshots instead of being append-only. This is what lets deleted or renamed server functions disappear after watch rebuilds or warm-cache restores. + +The pending transform-to-loader metadata maps are cleared on each Rspack `compile` hook. That prevents metadata discovered in an earlier compilation from being written to a later module when the transform no longer runs or no longer discovers server functions. + +## Why Snapshots Are Per Environment + +Each Rsbuild environment can see different information for the same file. For example, client and SSR caller environments can mark functions as client-accessible, while server and provider environments own server execution details. The merged registry preserves the broadest known access information. + +A single global replay pass would either drop another environment's metadata or keep stale metadata too long. The model is therefore: + +- Build one current snapshot per environment. +- Replace the shared registry with the union of all available snapshots. +- Regenerate resolver content from that shared registry. + +This keeps the global registry convergent while allowing Rspack environments to finish independently. + +## Resolver Rebuild Ordering + +The resolver virtual module must be rebuilt after cached metadata has been replayed. + +The ordering is: + +1. `finishMake` stage `-20`: replay `buildInfo` into environment snapshots and rebuild `serverFnsById`. +2. `finishMake` stage `-10`: write resolver virtual module content and rebuild modules that import the resolver. + +For non-RSC builds, each server-like environment that needs the resolver installs the stage `-10` rebuild hook. + +For RSC builds, the server environment installs the RSC resolver rebuild hook at stage `-10`. It generates provider-style resolver content because RSC server actions run inside the server/RSC environment and do not need the client-reference check in that resolver layer. + +## Compiler Ordering + +Non-RSC Rsbuild uses Rspack `MultiCompiler.setDependencies()` to make resolver-owning environments wait for metadata-producing environments. + +The intended order is: + +- `client` runs before every server-like environment. +- If a separate provider environment exists, the provider runs after `client`. +- If a separate provider environment exists, `ssr` runs after both `client` and the provider. + +That ordering prevents the `ssr` resolver module from being finalized before provider metadata is available. + +RSC builds intentionally do not add these dependencies. Rspack's native RSC coordinator interleaves server and client compilation phases. Adding MultiCompiler dependencies on top of it can deadlock the build. + +## Native APIs Used + +The implementation uses Rsbuild/Rspack integration points instead of cache internals: + +- Rsbuild `api.transform()` for source transforms. +- Rsbuild transform `ctx.resolve()` for module resolution. +- Rspack `compiler.inputFileSystem` for source reads. +- Rspack loaders for access to the loader pipeline. +- Rspack `NormalModule.getCompilationHooks(compilation).loader` to extend loader context. +- Rspack `module.buildInfo` for module-scoped persisted metadata. +- Rspack `compiler.hooks.finishMake` for module graph replay and resolver rebuild ordering. +- Rspack `compilation.rebuildModule()` through the local `rebuildModulesContaining()` helper. +- Rspack `MultiCompiler.setDependencies()` for non-RSC compiler ordering. +- Rspack `experiments.VirtualModulesPlugin` for virtual module content. + +The implementation avoids private persistent-cache files, direct cache mutation, direct `_module` access from loaders, global handoff state, and disabling module caching with `this.cacheable(false)`. + +## When A New Server Function Is Compiled + +For a new server function in a normal cold compile: + +1. Rsbuild sends the source module through the Start pre transform. +2. `StartCompiler.compile()` rewrites the caller module and discovers the server function. +3. `onServerFnsById` merges the function into `serverFnsById` and the active module metadata object. +4. The transform stores `{ version: 1, serverFnsById: discoveredServerFnsById }` in the environment metadata map under the module resource. +5. The post metadata loader runs for the same resource. +6. The loader writes that payload into `module.buildInfo['tanstack.start.serverFns']`. +7. Rspack owns the module from there. If persistent cache is enabled, Rspack persists the module and its string-keyed `buildInfo` with its normal cache machinery. +8. At `finishMake -20`, Start reads current module `buildInfo` and rebuilds the environment snapshot. +9. At `finishMake -10`, Start rewrites and rebuilds the resolver virtual module so runtime lookups can find the new function. diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index bf716ec8d0..8243d2e28b 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -1,7 +1,6 @@ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { hasKeys } from '@tanstack/router-core' import { joinURL } from 'ufo' import { applyResolvedBaseAndOutput, @@ -95,6 +94,18 @@ export function tanStackStartRsbuild( return { name: 'tanstack-start-rsbuild', setup(api: RsbuildPluginAPI) { + const startCompilerEnvironments = [ + { name: RSBUILD_ENVIRONMENT_NAMES.client, type: 'client' as const }, + { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' as const }, + ...(serverFnProviderEnv !== RSBUILD_ENVIRONMENT_NAMES.server && + !rscEnabled + ? [{ name: serverFnProviderEnv, type: 'server' as const }] + : []), + ] + const startCompilerServerEnvironmentNames = startCompilerEnvironments + .filter((env) => env.type === 'server') + .map((env) => env.name) + // --------------------------------------------------------------- // 1. modifyRsbuildConfig — resolve config, set up environments // --------------------------------------------------------------- @@ -258,6 +269,7 @@ export function tanStackStartRsbuild( // so defer this read until transform time instead of falling back to // process.cwd() during plugin setup. root: () => resolvedStartConfig.root || process.cwd(), + environments: startCompilerEnvironments, providerEnvName: serverFnProviderEnv, generateFunctionId: startPluginOpts.serverFns?.generateFunctionId, compilerTransforms: corePluginOpts.compilerTransforms, @@ -272,14 +284,7 @@ export function tanStackStartRsbuild( registerImportProtection(api, { getConfig, framework: corePluginOpts.framework, - environments: [ - { name: RSBUILD_ENVIRONMENT_NAMES.client, type: 'client' }, - { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' }, - ...(serverFnProviderEnv !== RSBUILD_ENVIRONMENT_NAMES.server && - !rscEnabled - ? [{ name: serverFnProviderEnv, type: 'server' as const }] - : []), - ], + environments: startCompilerEnvironments, }) // --------------------------------------------------------------- @@ -300,6 +305,37 @@ export function tanStackStartRsbuild( }) updateServerFnResolver = virtualModuleState.updateServerFnResolver + if (!rscEnabled) { + api.modifyRspackConfig((config, utils) => { + if ( + !startCompilerServerEnvironmentNames.includes( + utils.environment.name, + ) + ) { + return + } + + config.plugins.push({ + apply(compiler: RspackCompiler) { + compiler.hooks.finishMake.tapPromise( + { + name: 'TanStackStartServerFnResolverRebuild', + stage: -10, + }, + async (compilation: RspackCompilationExtended) => { + virtualModuleState.updateServerFnResolver() + + await rebuildModulesContaining( + compilation, + virtualModuleState.serverFnResolverPath, + ) + }, + ) + }, + }) + }) + } + // --------------------------------------------------------------- // 4. Client build stats capture via processAssets // --------------------------------------------------------------- @@ -491,10 +527,6 @@ export function tanStackStartRsbuild( stage: -10, }, async (compilation: RspackCompilationExtended) => { - if (!hasKeys(serverFnsById)) { - return - } - const resolverContent = virtualModuleState.generateCurrentResolverContent(true) virtualModuleState.tryUpdateServerFnResolver( @@ -586,13 +618,23 @@ export function tanStackStartRsbuild( api.onAfterCreateCompiler(({ compiler }) => { // MultiCompiler has a `compilers` array; single compiler does not if ('compilers' in compiler) { - const serverCompiler = compiler.compilers.find( - (c) => c.name === RSBUILD_ENVIRONMENT_NAMES.server, - ) - if (serverCompiler) { - compiler.setDependencies(serverCompiler, [ - RSBUILD_ENVIRONMENT_NAMES.client, - ]) + for (const environmentName of startCompilerServerEnvironmentNames) { + const serverCompiler = compiler.compilers.find( + (c) => c.name === environmentName, + ) + if (serverCompiler) { + const dependencies: Array = [ + RSBUILD_ENVIRONMENT_NAMES.client, + ] + if ( + environmentName === RSBUILD_ENVIRONMENT_NAMES.server && + serverFnProviderEnv !== RSBUILD_ENVIRONMENT_NAMES.server + ) { + dependencies.push(serverFnProviderEnv) + } + + compiler.setDependencies(serverCompiler, dependencies) + } } } }) diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts index d0ef77e34b..720f26aadd 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts @@ -1,5 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' -import { pathToFileURL } from 'node:url' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { z } from 'zod' import { TRANSFORM_ID_REGEX } from '../constants' import { detectKindsInCode } from '../start-compiler/compiler' import { getTransformCodeFilterForEnv } from '../start-compiler/config' @@ -11,8 +13,15 @@ import { } from '../start-compiler/host' import { cleanId } from '../start-compiler/utils' import { createHydrateCompilerPlugin } from '../hydrate-when-transform' -import { RSBUILD_ENVIRONMENT_NAMES } from './planning' +import { + SERVER_FN_BUILD_INFO_CONTEXT_KEY, + SERVER_FN_BUILD_INFO_FIELD, +} from './start-compiler-metadata' import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' +import type { + ServerFnBuildInfo, + ServerFnBuildInfoLoaderContext, +} from './start-compiler-metadata' import type { CompileStartFrameworkOptions, StartCompilerImportTransform, @@ -29,18 +38,74 @@ type RsbuildTransformContext = Parameters< Parameters[1] >[0] type RsbuildInputFileSystem = NonNullable +type ServerFnMetadataById = Map +type StartCompilerEnvironment = { + name: string + type: 'client' | 'server' +} + +const serverFnSchema = z.object({ + functionName: z.string(), + functionId: z.string(), + extractedFilename: z.string(), + filename: z.string(), + isClientReferenced: z.boolean().optional(), +}) + +const serverFnBuildInfoSchema = z.object({ + version: z.literal(1), + serverFnsById: z.record(z.string(), serverFnSchema), +}) /** - * Rsbuild dev server fn ref when: uses file:// URLs for absolute paths. + * In Rsbuild dev, use file:// URLs for absolute server function paths. * These are directly importable by Node's ESM VM runner without any bundler * path conventions (unlike Vite's /@id/ prefix). */ const rsbuildDevServerFnModuleSpecifierEncoder: DevServerFnModuleSpecifierEncoder = ({ extractedFilename }) => pathToFileURL(extractedFilename).href +const currentDir = dirname(fileURLToPath(import.meta.url)) +const metadataLoaderFilename = 'start-compiler-metadata-loader.js' +const EMPTY_SERVER_FN_BUILD_INFO: ServerFnBuildInfo = { + version: 1, + serverFnsById: {}, +} + +function resolveMetadataLoader(): string { + return resolve(currentDir, metadataLoaderFilename) +} + +function readServerFnBuildInfo( + module: Rspack.Module, +): Record | null { + const result = serverFnBuildInfoSchema.safeParse( + module.buildInfo[SERVER_FN_BUILD_INFO_FIELD], + ) + if (!result.success) { + return null + } + + return result.data.serverFnsById +} + +function setServerFnBuildInfoLoaderContext( + loaderContext: Rspack.LoaderContext & ServerFnBuildInfoLoaderContext, + module: Rspack.Module, +) { + loaderContext[SERVER_FN_BUILD_INFO_CONTEXT_KEY] = (metadata) => { + if (metadata) { + module.buildInfo[SERVER_FN_BUILD_INFO_FIELD] = metadata + } else if (module.buildInfo[SERVER_FN_BUILD_INFO_FIELD]) { + module.buildInfo[SERVER_FN_BUILD_INFO_FIELD] = EMPTY_SERVER_FN_BUILD_INFO + } + } +} + export interface StartCompilerHostOptions { framework: CompileStartFrameworkOptions root: string | (() => string) + environments: Array providerEnvName: string generateFunctionId?: GenerateFunctionIdFnOptional compilerTransforms?: Array | undefined @@ -63,15 +128,56 @@ export function registerStartCompilerTransforms( serverFnsById: Record } { const compilers = new Map>() + // Serialize access to each StartCompiler's mutable per-environment caches. + const compilerQueues = new Map>() const inputFileSystems = new Map() const transformContextStorage = new AsyncLocalStorage() + const serverFnMetadataByEnvironment = new Map() + const serverFnsByEnvironment = new Map>() const serverFnsById = opts.serverFnsById ?? {} const getRoot = () => typeof opts.root === 'function' ? opts.root() : opts.root + const getServerFnMetadata = (environmentName: string) => { + let metadataById = serverFnMetadataByEnvironment.get(environmentName) + + if (!metadataById) { + metadataById = new Map() + serverFnMetadataByEnvironment.set(environmentName, metadataById) + } + + return metadataById + } + const runCompilerTask = async ( + environmentName: string, + task: () => Promise, + ): Promise => { + const previous = compilerQueues.get(environmentName) ?? Promise.resolve() + const next = previous.catch(() => undefined).then(task) + + compilerQueues.set( + environmentName, + next.then( + () => undefined, + () => undefined, + ), + ) + + return next + } - const onServerFnsById = (d: Record) => { - mergeServerFnsById(serverFnsById, d) + const replaceServerFnsByIdFromEnvironmentSnapshots = () => { + const nextServerFnsById: Record = {} + + for (const snapshot of serverFnsByEnvironment.values()) { + mergeServerFnsById(nextServerFnsById, snapshot) + } + + for (const key of Object.keys(serverFnsById)) { + delete serverFnsById[key] + } + + Object.assign(serverFnsById, nextServerFnsById) opts.onServerFnsByIdChange?.() } const compilerPlugins = [ @@ -81,36 +187,57 @@ export function registerStartCompilerTransforms( const isDev = api.context.action === 'dev' const mode = isDev ? 'dev' : 'build' + const metadataLoader = resolveMetadataLoader() - const environments: Array<{ - name: string - type: 'client' | 'server' - }> = [ - { name: RSBUILD_ENVIRONMENT_NAMES.client, type: 'client' }, - { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' }, - ] + const environments = opts.environments - // Pre-compute code filter patterns per environment type - const codeFilters: Record<'client' | 'server', Array> = { - client: getTransformCodeFilterForEnv('client', { - compilerPlugins, - }), - server: getTransformCodeFilterForEnv('server', { - compilerTransforms: opts.compilerTransforms, - compilerPlugins, - }), - } + // Rspack persistent cache restores modules without re-running api.transform. + // Keep server function modules cacheable by writing discovered metadata into + // buildInfo from a real loader, then replaying it from cached modules. + api.modifyRspackConfig((config, utils) => { + if (!environments.some((env) => env.name === utils.environment.name)) { + return + } + + const rules = config.module.rules ?? [] + rules.push({ + test: TRANSFORM_ID_REGEX[0], + enforce: 'post', + use: [ + { + loader: metadataLoader, + options: { + metadataById: getServerFnMetadata(utils.environment.name), + }, + }, + ], + }) + config.module.rules = rules + }) for (const env of environments) { - const envCodeFilters = codeFilters[env.type] const compilerTransforms = - env.name === RSBUILD_ENVIRONMENT_NAMES.server + env.name === opts.providerEnvName ? opts.compilerTransforms : undefined + const envCodeFilters = getTransformCodeFilterForEnv(env.type, { + compilerTransforms, + compilerPlugins, + }) const serverFnProviderModuleDirectives = env.name === opts.providerEnvName ? opts.serverFnProviderModuleDirectives : undefined + let activeServerFnMetadata: Record | undefined + const onServerFnsById = (d: Record) => { + mergeServerFnsById(serverFnsById, d) + + if (activeServerFnMetadata) { + mergeServerFnsById(activeServerFnMetadata, d) + } + + opts.onServerFnsByIdChange?.() + } api.transform( { @@ -126,7 +253,7 @@ export function registerStartCompilerTransforms( code: string map: StartCompilerTransformResult['map'] } | null = null - const id = ctx.resourcePath + (ctx.resourceQuery || '') + const id = ctx.resource const root = getRoot() const virtualResult = loadCompilerVirtualModule(compilerPlugins, { @@ -232,10 +359,24 @@ export function registerStartCompilerTransforms( const detectedKinds = detectKindsInCode(nextCode, env.type, { compilerTransforms, }) - const result = await compiler.compile({ - id, - code: nextCode, - detectedKinds, + const discoveredServerFnsById: Record = {} + const result = await runCompilerTask(env.name, async () => { + activeServerFnMetadata = discoveredServerFnsById + + try { + return await compiler.compile({ + id, + code: nextCode, + detectedKinds, + }) + } finally { + activeServerFnMetadata = undefined + } + }) + + getServerFnMetadata(env.name).set(id, { + version: 1, + serverFnsById: discoveredServerFnsById, }) if (result) { @@ -252,12 +393,60 @@ export function registerStartCompilerTransforms( } api.modifyRspackConfig((config, utils) => { + if (!environments.some((env) => env.name === utils.environment.name)) { + return + } + config.plugins.push({ apply(compiler: Rspack.Compiler) { if (compiler.inputFileSystem) { inputFileSystems.set(utils.environment.name, compiler.inputFileSystem) } + compiler.hooks.compilation.tap( + 'TanStackStartCompilerMetadataLoaderContext', + (compilation) => { + utils.rspack.NormalModule.getCompilationHooks( + compilation, + ).loader.tap( + 'TanStackStartCompilerMetadataLoaderContext', + (loaderContext, module) => { + setServerFnBuildInfoLoaderContext(loaderContext, module) + }, + ) + }, + ) + + compiler.hooks.compile.tap( + 'TanStackStartCompilerMetadataCleanup', + () => getServerFnMetadata(utils.environment.name).clear(), + ) + + compiler.hooks.finishMake.tap( + { + name: 'TanStackStartCompilerCachedServerFnMetadata', + stage: -20, + }, + (compilation) => { + const restoredServerFnsById: Record = {} + + for (const module of compilation.modules) { + const metadata = readServerFnBuildInfo(module) + if (!metadata) { + continue + } + + mergeServerFnsById(restoredServerFnsById, metadata) + } + + serverFnsByEnvironment.set( + utils.environment.name, + restoredServerFnsById, + ) + replaceServerFnsByIdFromEnvironmentSnapshots() + }, + ) + compiler.hooks.watchRun.tap( 'TanStackStartCompilerModuleInvalidation', (watchCompiler) => { diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-metadata-loader.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata-loader.ts new file mode 100644 index 0000000000..89a22d82cd --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata-loader.ts @@ -0,0 +1,22 @@ +import { SERVER_FN_BUILD_INFO_CONTEXT_KEY } from './start-compiler-metadata' +import type { Rspack } from '@rsbuild/core' +import type { + ServerFnBuildInfoLoaderContext, + ServerFnMetadataLoaderOptions, +} from './start-compiler-metadata' + +const tanStackStartCompilerMetadataLoader: Rspack.LoaderDefinition< + ServerFnMetadataLoaderOptions, + ServerFnBuildInfoLoaderContext +> = function (source, map): void { + const { metadataById } = this.getOptions() + const id = this.resource + const metadata = metadataById.get(id) + const setBuildInfo = this[SERVER_FN_BUILD_INFO_CONTEXT_KEY] + + setBuildInfo?.(metadata ?? null) + + this.callback(null, source, map) +} + +export default tanStackStartCompilerMetadataLoader diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts new file mode 100644 index 0000000000..c474632dbf --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts @@ -0,0 +1,23 @@ +import type { ServerFn } from '../start-compiler/types' + +export const SERVER_FN_BUILD_INFO_FIELD = + 'tanstack.start.serverFns' +export const SERVER_FN_BUILD_INFO_CONTEXT_KEY = + 'tanstack.start.setServerFnBuildInfo' + +export type ServerFnBuildInfo = { + version: 1 + serverFnsById: Record +} + +export type SetServerFnBuildInfo = ( + metadata: ServerFnBuildInfo | null, +) => void + +export type ServerFnBuildInfoLoaderContext = { + [SERVER_FN_BUILD_INFO_CONTEXT_KEY]?: SetServerFnBuildInfo +} + +export type ServerFnMetadataLoaderOptions = { + metadataById: Map +} diff --git a/packages/start-plugin-core/vite.config.ts b/packages/start-plugin-core/vite.config.ts index 5f52ed13b3..fc76204069 100644 --- a/packages/start-plugin-core/vite.config.ts +++ b/packages/start-plugin-core/vite.config.ts @@ -21,6 +21,7 @@ export default mergeConfig( './src/vite/index.ts', './src/rsbuild/index.ts', './src/rsbuild/types.ts', + './src/rsbuild/start-compiler-metadata-loader.ts', ], srcDir: './src', outDir: './dist', From 0d95f19e45230059677fe4c372332c8bddad9b46 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 24 May 2026 01:46:03 +0200 Subject: [PATCH 05/10] fix tests --- e2e/react-start/csp/tests/csp.spec.ts | 34 ++++++++++++------- .../tests/hydration.spec.ts | 22 ++++++++---- .../tests/ssr-scroll-key.spec.ts | 9 +++-- .../tests/server-functions.spec.ts | 4 +-- e2e/solid-start/csp/tests/csp.spec.ts | 34 ++++++++++++------- 5 files changed, 64 insertions(+), 39 deletions(-) diff --git a/e2e/react-start/csp/tests/csp.spec.ts b/e2e/react-start/csp/tests/csp.spec.ts index 5bdfd93e9b..ef6b2dadc6 100644 --- a/e2e/react-start/csp/tests/csp.spec.ts +++ b/e2e/react-start/csp/tests/csp.spec.ts @@ -1,6 +1,21 @@ -import { expect } from '@playwright/test' +import { expect, type Page } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' +async function getRawHtml(page: Page) { + let rawHtml = '' + + await page.route('/', async (route) => { + const response = await route.fetch() + rawHtml = await response.text() + await route.fulfill({ response, body: rawHtml }) + }) + + const response = await page.goto('/') + await page.unrouteAll({ behavior: 'ignoreErrors' }) + + return { rawHtml, response } +} + test('CSP header is set with nonce', async ({ page }) => { const response = await page.goto('/') const csp = response?.headers()['content-security-policy'] @@ -21,9 +36,10 @@ test('Inline styles have nonce attribute', async ({ page }) => { }) test('External script has nonce attribute', async ({ page }) => { - await page.goto('/') - const externalScript = page.locator('script[src="/external.js"]') - await expect(externalScript).toHaveAttribute('nonce') + const { rawHtml } = await getRawHtml(page) + expect(rawHtml).toMatch( + /]*\bsrc="\/external\.js")(?=[^>]*\bnonce="[^"]+")[^>]*>/, + ) }) test('External stylesheet has nonce attribute', async ({ page }) => { @@ -34,15 +50,7 @@ test('External stylesheet has nonce attribute', async ({ page }) => { test('Nonces match between header and elements', async ({ page }) => { // Intercept the HTML response to get raw content before browser strips nonces - let rawHtml = '' - await page.route('/', async (route) => { - const response = await route.fetch() - rawHtml = await response.text() - await route.fulfill({ response }) - }) - - const response = await page.goto('/') - await page.unrouteAll({ behavior: 'ignoreErrors' }) + const { rawHtml, response } = await getRawHtml(page) const csp = response?.headers()['content-security-policy'] || '' diff --git a/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts b/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts index 2d543c5406..d700863d1e 100644 --- a/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts +++ b/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts @@ -18,6 +18,11 @@ async function clickAndExpectCount(page: Page, id: string, count: string) { await expect(page.getByTestId(`${id}-count`)).toHaveText(count) } +async function gotoWithoutPointerIntent(page: Page, path: string) { + await page.mouse.move(0, 0) + await page.goto(path) +} + async function waitForHydrateMarkerToMount(page: Page, id: string) { await page.waitForFunction((testId) => { const button = document.querySelector(`[data-testid="${testId}-button"]`) @@ -30,7 +35,7 @@ test.describe('RSC deferred hydration', () => { test('server component renders a client Hydrate island that hydrates on interaction', async ({ page, }) => { - await page.goto('/server-client') + await gotoWithoutPointerIntent(page, '/server-client') await expect(page.getByTestId('server-client-rsc')).toContainText( 'Server component renders a deferred client island', @@ -40,6 +45,7 @@ test.describe('RSC deferred hydration', () => { ) await expectUnhydrated(page, 'server-client') + await page.mouse.move(0, 0) await page.getByTestId('server-client-button').hover() await clickAndExpectCount(page, 'server-client', '1') }) @@ -47,7 +53,7 @@ test.describe('RSC deferred hydration', () => { test('composite server component can wrap an interaction Hydrate client island', async ({ page, }) => { - await page.goto('/composite') + await gotoWithoutPointerIntent(page, '/composite') await expect(page.getByTestId('composite-rsc')).toContainText( 'Server shell, client Hydrate slot', @@ -58,6 +64,7 @@ test.describe('RSC deferred hydration', () => { await expectUnhydrated(page, 'composite-interaction') await waitForHydrateMarkerToMount(page, 'composite-interaction') + await page.mouse.move(0, 0) await page.getByTestId('composite-interaction-button').hover() await clickAndExpectCount(page, 'composite-interaction', '1') }) @@ -65,7 +72,7 @@ test.describe('RSC deferred hydration', () => { test('server component can render a CSS module Hydrate client island', async ({ page, }) => { - await page.goto('/css') + await gotoWithoutPointerIntent(page, '/css') await expect(page.getByTestId('css-rsc')).toContainText( 'CSS module Hydrate boundary', @@ -74,9 +81,12 @@ test.describe('RSC deferred hydration', () => { 'font-weight', '900', ) - await expectUnhydrated(page, 'css-nested') await waitForHydrateMarkerToMount(page, 'css-nested') - await page.getByTestId('css-nested-button').hover() - await clickAndExpectCount(page, 'css-nested', '1') + await page.getByTestId('css-nested-button').click() + await expect(page.getByTestId('css-nested-button')).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await expect(page.getByTestId('css-nested-count')).toHaveText('1') }) }) diff --git a/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts b/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts index 39b2f3e8d2..737695a0cf 100644 --- a/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts +++ b/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts @@ -8,8 +8,9 @@ test('SSR scroll restoration uses a custom restoration key', async ({ const customKeyScrollY = 650 const historyKeyScrollY = 80 - await page.goto('/') - await page.evaluate( + // Seed storage during document initialization so a hydrated previous page + // cannot persist an empty in-memory scroll cache over these entries. + await page.addInitScript( ({ customKeyScrollY, historyKeyScrollY, storageKey }) => { window.sessionStorage.setItem( storageKey, @@ -22,13 +23,11 @@ test('SSR scroll restoration uses a custom restoration key', async ({ }, }), ) + window.history.replaceState({ __TSR_key: 'history-key' }, '') }, { customKeyScrollY, historyKeyScrollY, storageKey }, ) - await page.addInitScript(() => { - window.history.replaceState({ __TSR_key: 'history-key' }, '') - }) await page.route(/\/assets\/.*\.js$/, (route) => route.abort()) await page.goto('/ssr-scroll-key', { waitUntil: 'domcontentloaded' }) diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts index 9b23cb8a2a..bbd0fe91c4 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -770,8 +770,8 @@ test('redirect via server function with middleware does not cause serialization }) test.describe('unhandled exception in middleware (issue #5266)', () => { - // Whitelist the expected 500 error since this test verifies error handling - test.use({ whitelistErrors: ['500'] }) + // Whitelist expected browser console errors since this test verifies error handling. + test.use({ whitelistErrors: ['500', 'Unhandled middleware exception'] }) test('does not crash server and shows error component', async ({ page }) => { // This test verifies that when a middleware throws an unhandled exception, diff --git a/e2e/solid-start/csp/tests/csp.spec.ts b/e2e/solid-start/csp/tests/csp.spec.ts index 247f3b5965..f299e73399 100644 --- a/e2e/solid-start/csp/tests/csp.spec.ts +++ b/e2e/solid-start/csp/tests/csp.spec.ts @@ -1,6 +1,21 @@ -import { expect } from '@playwright/test' +import { expect, type Page } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' +async function getRawHtml(page: Page) { + let rawHtml = '' + + await page.route('/', async (route) => { + const response = await route.fetch() + rawHtml = await response.text() + await route.fulfill({ response, body: rawHtml }) + }) + + const response = await page.goto('/') + await page.unrouteAll({ behavior: 'ignoreErrors' }) + + return { rawHtml, response } +} + test('CSP header is set with nonce', async ({ page }) => { const response = await page.goto('/') const csp = response?.headers()['content-security-policy'] @@ -21,9 +36,10 @@ test('Inline styles have nonce attribute', async ({ page }) => { }) test('External script has nonce attribute', async ({ page }) => { - await page.goto('/') - const externalScript = page.locator('script[src="/external.js"]') - await expect(externalScript).toHaveAttribute('nonce') + const { rawHtml } = await getRawHtml(page) + expect(rawHtml).toMatch( + /]*\bsrc="\/external\.js")(?=[^>]*\bnonce="[^"]+")[^>]*>/, + ) }) test('External stylesheet has nonce attribute', async ({ page }) => { @@ -34,15 +50,7 @@ test('External stylesheet has nonce attribute', async ({ page }) => { test('Nonces match between header and elements', async ({ page }) => { // Intercept the HTML response to get raw content before browser strips nonces - let rawHtml = '' - await page.route('/', async (route) => { - const response = await route.fetch() - rawHtml = await response.text() - await route.fulfill({ response }) - }) - - const response = await page.goto('/') - await page.unrouteAll({ behavior: 'ignoreErrors' }) + const { rawHtml, response } = await getRawHtml(page) const csp = response?.headers()['content-security-policy'] || '' From 19f111add434d6f3618718c33ef57893ef058ef8 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 24 May 2026 01:46:03 +0200 Subject: [PATCH 06/10] simplify --- packages/router-core/src/index.ts | 1 - packages/router-core/src/manifest.ts | 8 - packages/router-core/src/router.ts | 4 +- packages/router-core/src/ssr/ssr-server.ts | 326 +++++++++--------- .../tests/ssr-server-manifest.test.ts | 19 +- 5 files changed, 170 insertions(+), 188 deletions(-) diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 13c60d22cc..fd673ca410 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -99,7 +99,6 @@ export { getManifestScriptFormat, getScriptPreloadAttrs, getStylesheetHref, - isInlinableStylesheet, resolveManifestAssetLink, resolveManifestCssLink, } from './manifest' diff --git a/packages/router-core/src/manifest.ts b/packages/router-core/src/manifest.ts index cac62182bc..aca4b96eac 100644 --- a/packages/router-core/src/manifest.ts +++ b/packages/router-core/src/manifest.ts @@ -195,14 +195,6 @@ export function resolveManifestCssLink(link: ManifestCssLink) { return link } -export function isInlinableStylesheet( - manifest: ServerManifest | undefined, - asset: ManifestCssLink, -) { - const href = getStylesheetHref(asset) - return manifest?.inlineCss?.styles[href] !== undefined -} - export function createInlineCssStyleAsset(css: string): ManifestInlineCss { return { attrs: { diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 177b45b2ef..14f086d0dc 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -807,9 +807,7 @@ export interface ServerSsr { setRenderFinished: () => void cleanup: () => void onSerializationFinished: (listener: () => void) => void - dehydrate: (opts?: { - requestAssets?: ManifestRouteAssets - }) => Promise + dehydrate: (opts?: { requestAssets?: ManifestRouteAssets }) => Promise takeBufferedScripts: () => RouterManagedTag | undefined /** * Takes any buffered HTML that was injected. diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index ce98e70b32..ce61ee2077 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -4,7 +4,6 @@ import { createInlineCssPlaceholderAsset, createInlineCssStyleAsset, getStylesheetHref, - isInlinableStylesheet, } from '../manifest' import { decodePath } from '../utils' import { createLRUCache } from '../lru-cache' @@ -168,148 +167,182 @@ const isProd = process.env.NODE_ENV === 'production' type FilteredRoutes = Manifest['routes'] -type ManifestLRU = LRUCache -type InlineCssLRU = LRUCache +type PreparedMatchedManifestRoutes = { + routes: FilteredRoutes + hasStrippedRoutes: boolean + inlineCssHrefs?: Array + inlineCss?: string +} + +type ManifestLRU = LRUCache const MANIFEST_CACHE_SIZE = 100 const manifestCaches = new WeakMap() -const inlineCssCaches = new WeakMap() function getManifestCache(manifest: ServerManifest): ManifestLRU { const cache = manifestCaches.get(manifest) if (cache) return cache - const newCache = createLRUCache(MANIFEST_CACHE_SIZE) + const newCache = createLRUCache( + MANIFEST_CACHE_SIZE, + ) manifestCaches.set(manifest, newCache) return newCache } -function getInlineCssCache(manifest: ServerManifest): InlineCssLRU { - const cache = inlineCssCaches.get(manifest) - if (cache) return cache - const newCache = createLRUCache(MANIFEST_CACHE_SIZE) - inlineCssCaches.set(manifest, newCache) - return newCache -} - -function getInlineCssHrefsForMatches( - manifest: ServerManifest | undefined, - matches: Array, +function getInlineCssForPreparedRoutes( + manifest: ServerManifest, + preparedRoutes: PreparedMatchedManifestRoutes, ) { - if (!manifest) { - return [] - } + if (preparedRoutes.inlineCss !== undefined) return preparedRoutes.inlineCss - const inlineStyles = manifest.inlineCss?.styles - if (!inlineStyles) { - return [] - } + const styles = manifest.inlineCss?.styles + const hrefs = preparedRoutes.inlineCssHrefs + if (!styles || !hrefs?.length) return undefined - const seen = new Set() - const hrefs: Array = [] - const routes = manifest.routes + let css = '' + for (const href of hrefs) { + css += styles[href]! + } - for (const match of matches) { - const cssLinks = routes[match.routeId]?.css - if (!cssLinks) { - continue - } + preparedRoutes.inlineCss = css + return css +} - for (const link of cssLinks) { - const href = getStylesheetHref(link) - if (seen.has(href) || inlineStyles[href] === undefined) { - continue - } - seen.add(href) - hrefs.push(href) - } - } +function getInlineCssAssetForPreparedRoutes( + manifest: ServerManifest, + preparedRoutes: PreparedMatchedManifestRoutes, +) { + const css = getInlineCssForPreparedRoutes(manifest, preparedRoutes) - return hrefs + return css === undefined ? undefined : createInlineCssStyleAsset(css) } -function getInlineCssForHrefs(manifest: ServerManifest, hrefs: Array) { - const styles = manifest.inlineCss?.styles - if (!styles || hrefs.length === 0) return undefined +function getMatchedRoutesCacheKey(matches: Array) { + let cacheKey = '' + for (let i = 0; i < matches.length; i++) { + cacheKey += (i === 0 ? '' : '\0') + matches[i]!.routeId + } + return cacheKey +} - const cacheKey = hrefs.join('\0') +function getPreparedMatchedManifestRoutes( + manifest: ServerManifest, + matches: Array, + cacheKey: string, +) { if (isProd) { - const cached = getInlineCssCache(manifest).get(cacheKey) - if (cached !== undefined) return cached + const cached = getManifestCache(manifest).get(cacheKey) + if (cached) { + return cached + } } - let css = '' - for (const href of hrefs) { - css += styles[href]! - } + const preparedRoutes = prepareMatchedManifestRoutes(manifest, matches) if (isProd) { - getInlineCssCache(manifest).set(cacheKey, css) + getManifestCache(manifest).set(cacheKey, preparedRoutes) } - return css + return preparedRoutes } -function getInlineCssAssetForMatches( - manifest: ServerManifest | undefined, +function prepareMatchedManifestRoutes( + manifest: ServerManifest, matches: Array, -) { - if (!manifest?.inlineCss) return undefined +): PreparedMatchedManifestRoutes { + const inlineStyles = manifest.inlineCss?.styles + const routes: FilteredRoutes = {} + + if (!inlineStyles) { + for (const match of matches) { + const route = manifest.routes[match.routeId] + if (route) { + routes[match.routeId] = route + } + } + return { routes, hasStrippedRoutes: false } + } - const hrefs = getInlineCssHrefsForMatches(manifest, matches) - const css = getInlineCssForHrefs(manifest, hrefs) + const inlineCssHrefs: Array = [] + const seenInlineCssHrefs = new Set() + let hasStrippedRoutes = false - return css === undefined ? undefined : createInlineCssStyleAsset(css) -} + for (const match of matches) { + const routeId = match.routeId + const route = manifest.routes[routeId] + if (!route) { + continue + } -function stripInlinedStylesheetAssets( - manifest: ServerManifest, - routes: FilteredRoutes, -): FilteredRoutes { - if (!manifest.inlineCss) { - return routes - } + const nextRoute = stripInlinedStylesheetAssetsFromRoute( + inlineStyles, + route, + inlineCssHrefs, + seenInlineCssHrefs, + ) - const nextRoutes: FilteredRoutes = {} - let changed = false + if (nextRoute !== route) { + hasStrippedRoutes = true + } + routes[routeId] = nextRoute + } - for (const [routeId, route] of Object.entries(routes)) { - const css = route.css - let cssLinks: typeof css | undefined + return { + routes, + hasStrippedRoutes, + ...(inlineCssHrefs.length ? { inlineCssHrefs } : {}), + } +} - if (css) { - if (css.length === 0) { - changed = true - cssLinks = [] - } +function stripInlinedStylesheetAssetsFromRoute( + inlineStyles: Record, + route: ManifestRoute, + inlineCssHrefs: Array, + seenInlineCssHrefs: Set, +): ManifestRoute { + const css = route.css + if (!css) { + return route + } - for (let i = 0; i < css.length; i++) { - const link = css[i]! - if (!isInlinableStylesheet(manifest, link)) { - if (cssLinks) { - cssLinks.push(link) - } - continue - } + if (css.length === 0) { + const nextRoute = { ...route } + delete nextRoute.css + return nextRoute + } - changed = true - if (!cssLinks) { - cssLinks = css.slice(0, i) - } + let cssLinks: typeof css | undefined + for (let i = 0; i < css.length; i++) { + const link = css[i]! + const href = getStylesheetHref(link) + if (inlineStyles[href] === undefined) { + if (cssLinks) { + cssLinks.push(link) } + continue } - if (cssLinks) { - nextRoutes[routeId] = - cssLinks.length > 0 ? { ...route, css: cssLinks } : { ...route } - if (cssLinks.length === 0) { - delete nextRoutes[routeId].css - } - } else { - nextRoutes[routeId] = route + if (!seenInlineCssHrefs.has(href)) { + seenInlineCssHrefs.add(href) + inlineCssHrefs.push(href) + } + + if (!cssLinks) { + cssLinks = css.slice(0, i) } } - return changed ? nextRoutes : routes + if (!cssLinks) { + return route + } + + if (cssLinks.length > 0) { + return { ...route, css: cssLinks } + } + + const nextRoute = { ...route } + delete nextRoute.css + return nextRoute } function hasRouteAssets(route: ManifestRoute) { @@ -346,12 +379,10 @@ export function attachRouterServerSsrUtils({ router, manifest, getRequestAssets, - includeUnmatchedRouteAssets = true, }: { router: AnyRouter manifest: ServerManifest | undefined getRequestAssets?: () => ManifestRouteAssets | undefined - includeUnmatchedRouteAssets?: boolean }) { router.ssr = { get manifest() { @@ -359,18 +390,31 @@ export function attachRouterServerSsrUtils({ const requestAssets = getRequestAssets?.() const matches = router.stores.matches.get() - const inlineCssAsset = getInlineCssAssetForMatches(manifest, matches) + const hasAssets = hasRequestAssets(requestAssets) - if ( - !hasRequestAssets(requestAssets) && - !inlineCssAsset && - !manifest.inlineCss - ) { + if (!hasAssets && !manifest.inlineCss) { return manifest } - const routes = stripInlinedStylesheetAssets(manifest, manifest.routes) - if (!hasRequestAssets(requestAssets)) { + let inlineCssAsset: Manifest['inlineStyle'] | undefined + let routes = manifest.routes + if (manifest.inlineCss) { + const cacheKey = getMatchedRoutesCacheKey(matches) + const preparedManifest = getPreparedMatchedManifestRoutes( + manifest, + matches, + cacheKey, + ) + inlineCssAsset = getInlineCssAssetForPreparedRoutes( + manifest, + preparedManifest, + ) + if (preparedManifest.hasStrippedRoutes) { + routes = { ...manifest.routes, ...preparedManifest.routes } + } + } + + if (!hasAssets) { return { ...(manifest.scriptFormat ? { scriptFormat: manifest.scriptFormat } @@ -436,73 +480,37 @@ export function attachRouterServerSsrUtils({ const matches = matchesToDehydrate.map(dehydrateMatch) let manifestToDehydrate: Manifest | undefined = undefined - // For currently matched routes, send full manifest data. - // For unmatched routes, include renderable assets only when includeUnmatchedRouteAssets - // is true; otherwise omit them entirely. Preloads for unmatched routes are - // still excluded because they are handled via dynamic imports. + // Only currently matched routes are dehydrated. Other route assets are + // loaded through dynamic imports when those routes become active. if (manifest) { - // Prod-only caching; in dev manifests may be replaced/updated (HMR) - const currentRouteIdsList = matchesToDehydrate.map((m) => m.routeId) - const manifestCacheKey = `${currentRouteIdsList.join('\0')}\0includeUnmatchedRouteAssets=${includeUnmatchedRouteAssets}` - - let filteredRoutes: FilteredRoutes | undefined - - if (isProd) { - filteredRoutes = getManifestCache(manifest).get(manifestCacheKey) - } - - if (!filteredRoutes) { - const currentRouteIds = new Set(currentRouteIdsList) - const nextFilteredRoutes: FilteredRoutes = {} - - for (const routeId in manifest.routes) { - const routeManifest = manifest.routes[routeId]! - if (currentRouteIds.has(routeId)) { - nextFilteredRoutes[routeId] = routeManifest - } else if ( - includeUnmatchedRouteAssets && - hasRouteAssets(routeManifest) - ) { - nextFilteredRoutes[routeId] = { - ...(routeManifest.scripts - ? { scripts: routeManifest.scripts } - : {}), - ...(routeManifest.css ? { css: routeManifest.css } : {}), - } - } - } - - filteredRoutes = stripInlinedStylesheetAssets( - manifest, - nextFilteredRoutes, - ) - - if (isProd) { - getManifestCache(manifest).set(manifestCacheKey, filteredRoutes) - } - } - - const inlineCssAsset = getInlineCssAssetForMatches( + const cacheKey = getMatchedRoutesCacheKey(matchesToDehydrate) + const preparedManifest = getPreparedMatchedManifestRoutes( manifest, matchesToDehydrate, + cacheKey, ) manifestToDehydrate = { ...(manifest.scriptFormat ? { scriptFormat: manifest.scriptFormat } : {}), - ...(inlineCssAsset + ...(preparedManifest.inlineCssHrefs ? { inlineStyle: createInlineCssPlaceholderAsset() } : {}), - routes: { ...filteredRoutes }, + routes: preparedManifest.routes, } // Merge request-scoped assets into root route (without mutating cached manifest) const requestAssets = opts?.requestAssets if (hasRequestAssets(requestAssets)) { const existingRoot = manifestToDehydrate.routes[rootRouteId] - manifestToDehydrate.routes[rootRouteId] = - mergeRequestAssetsIntoRootRoute(existingRoot, requestAssets) + manifestToDehydrate.routes = { + ...manifestToDehydrate.routes, + [rootRouteId]: mergeRequestAssetsIntoRootRoute( + existingRoot, + requestAssets, + ), + } } } const dehydratedRouter: DehydratedRouter = { diff --git a/packages/router-core/tests/ssr-server-manifest.test.ts b/packages/router-core/tests/ssr-server-manifest.test.ts index 22a98e3b46..f76535622f 100644 --- a/packages/router-core/tests/ssr-server-manifest.test.ts +++ b/packages/router-core/tests/ssr-server-manifest.test.ts @@ -67,16 +67,13 @@ function buildInlineManifest(): ServerManifest { } } -async function dehydrateManifest(options?: { - includeUnmatchedRouteAssets?: boolean -}) { +async function dehydrateManifest() { const router = buildRouter() const manifest = buildManifest() attachRouterServerSsrUtils({ router, manifest, - includeUnmatchedRouteAssets: options?.includeUnmatchedRouteAssets, }) await router.load() @@ -107,19 +104,9 @@ function parseSerializedRouter(serialized: string): DehydratedRouter { } describe('attachRouterServerSsrUtils manifest dehydration', () => { - test('includes unmatched route assets by default', async () => { + test('omits unmatched route assets by default', async () => { const manifest = await dehydrateManifest() - expect(manifest.routes['/posts']).toEqual({ - css: ['/assets/shared.css'], - }) - }) - - test('omits unmatched route assets when disabled', async () => { - const manifest = await dehydrateManifest({ - includeUnmatchedRouteAssets: false, - }) - expect(manifest.routes['/posts']).toBeUndefined() expect(manifest.routes['/']?.preloads).toEqual(['/assets/index.js']) }) @@ -312,7 +299,6 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { attachRouterServerSsrUtils({ router, manifest, - includeUnmatchedRouteAssets: false, }) await router.load() @@ -386,7 +372,6 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { attachRouterServerSsrUtils({ router, manifest, - includeUnmatchedRouteAssets: false, }) await router.load() From 7a723a422c0bd266337c62b08b0c6c42a0d212f7 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 24 May 2026 01:46:03 +0200 Subject: [PATCH 07/10] typing --- packages/solid-router/src/Scripts.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/solid-router/src/Scripts.tsx b/packages/solid-router/src/Scripts.tsx index da3ceef65d..4c1ece99c6 100644 --- a/packages/solid-router/src/Scripts.tsx +++ b/packages/solid-router/src/Scripts.tsx @@ -2,13 +2,13 @@ import * as Solid from 'solid-js' import { isServer } from '@tanstack/router-core/isServer' import { Asset } from './Asset' import { useRouter } from './useRouter' -import type { RouterManagedTag } from '@tanstack/router-core' +import type { AnyRouteMatch, RouterManagedTag } from '@tanstack/router-core' export const Scripts = () => { const router = useRouter() const nonce = router.options.ssr?.nonce - const getAssetScripts = (matches: Array) => { + const getAssetScripts = (matches: Array) => { const assetScripts: Array = [] const manifest = router.ssr?.manifest @@ -35,7 +35,7 @@ export const Scripts = () => { return assetScripts } - const getScripts = (matches: Array): Array => + const getScripts = (matches: Array): Array => ( matches .map((match) => match.scripts!) From b1ca5324671cb19c8f26389d88261ec33b36a450 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 24 May 2026 01:46:03 +0200 Subject: [PATCH 08/10] formatting --- .../start-plugin-core/src/rsbuild/start-compiler-host.ts | 9 +++------ .../src/rsbuild/start-compiler-metadata.ts | 7 ++----- .../tests/start-manifest-plugin/manifestBuilder.test.ts | 8 ++------ packages/start-server-core/src/createStartHandler.ts | 1 - packages/start-server-core/src/finalManifest.ts | 6 ++++-- 5 files changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts index 720f26aadd..5cf9319238 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts @@ -217,9 +217,7 @@ export function registerStartCompilerTransforms( for (const env of environments) { const compilerTransforms = - env.name === opts.providerEnvName - ? opts.compilerTransforms - : undefined + env.name === opts.providerEnvName ? opts.compilerTransforms : undefined const envCodeFilters = getTransformCodeFilterForEnv(env.type, { compilerTransforms, compilerPlugins, @@ -417,9 +415,8 @@ export function registerStartCompilerTransforms( }, ) - compiler.hooks.compile.tap( - 'TanStackStartCompilerMetadataCleanup', - () => getServerFnMetadata(utils.environment.name).clear(), + compiler.hooks.compile.tap('TanStackStartCompilerMetadataCleanup', () => + getServerFnMetadata(utils.environment.name).clear(), ) compiler.hooks.finishMake.tap( diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts index c474632dbf..dfa6119246 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts @@ -1,7 +1,6 @@ import type { ServerFn } from '../start-compiler/types' -export const SERVER_FN_BUILD_INFO_FIELD = - 'tanstack.start.serverFns' +export const SERVER_FN_BUILD_INFO_FIELD = 'tanstack.start.serverFns' export const SERVER_FN_BUILD_INFO_CONTEXT_KEY = 'tanstack.start.setServerFnBuildInfo' @@ -10,9 +9,7 @@ export type ServerFnBuildInfo = { serverFnsById: Record } -export type SetServerFnBuildInfo = ( - metadata: ServerFnBuildInfo | null, -) => void +export type SetServerFnBuildInfo = (metadata: ServerFnBuildInfo | null) => void export type ServerFnBuildInfoLoaderContext = { [SERVER_FN_BUILD_INFO_CONTEXT_KEY]?: SetServerFnBuildInfo diff --git a/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts b/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts index f2a06de418..91bf8ff90d 100644 --- a/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts +++ b/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts @@ -691,12 +691,8 @@ describe('buildStartManifest', () => { }) const expectedLink = makeStylesheetLink('/assets/shared-hydrate.css') - expect(manifest.routes['/about']!.css).toEqual([ - expectedLink, - ]) - expect(manifest.routes['/posts']!.css).toEqual([ - expectedLink, - ]) + expect(manifest.routes['/about']!.css).toEqual([expectedLink]) + expect(manifest.routes['/posts']!.css).toEqual([expectedLink]) expect(manifest.routes['/about']!.preloads).toEqual([ '/assets/about.js', '/assets/shared-widget.js', diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 7d47388a3c..0952576051 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -497,7 +497,6 @@ export function createStartHandler( manifest, getRequestAssets: () => getStartContext({ throwIfNotFound: false })?.requestAssets, - includeUnmatchedRouteAssets: false, }) routerInstance.update({ additionalContext: { serverContext } }) diff --git a/packages/start-server-core/src/finalManifest.ts b/packages/start-server-core/src/finalManifest.ts index 7cb5d35739..a1edd4fd79 100644 --- a/packages/start-server-core/src/finalManifest.ts +++ b/packages/start-server-core/src/finalManifest.ts @@ -66,9 +66,11 @@ interface FinalManifestTransformResolver { export interface FinalManifestResolver { warmup: (opts: { getBaseManifest: GetBaseManifest - }) => Promise | undefined + }) => Promise | undefined resolveCached: (opts: FinalManifestRequestOptions) => Promise - resolveUncached: (opts: FinalManifestRequestOptions) => Promise + resolveUncached: ( + opts: FinalManifestRequestOptions, + ) => Promise } export function createCachedBaseManifestLoader( From c5c48ed7b16524b7aecc1be9474c62d580c95fcb Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 24 May 2026 01:46:03 +0200 Subject: [PATCH 09/10] fix --- .../tests/transform-asset-urls.spec.ts | 12 ++++-- .../src/import-protection/virtualModules.ts | 10 ++--- .../importProtection/virtualModules.test.ts | 42 ++++++++++++++++++- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts b/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts index e81fe7ca8c..eb02863b3b 100644 --- a/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts +++ b/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts @@ -45,12 +45,16 @@ test.describe('transformAssets with CDN prefix', () => { const html = await getSSRHtml(page) // All script preload links should point to the CDN origin - const scriptPreloads = html.match(/rel="modulepreload"[^>]*href="([^"]+)"/g) + const scriptPreloads = Array.from( + html.matchAll( + /]*\brel="modulepreload")(?=[^>]*\bhref="([^"]+)")[^>]*>/g, + ), + ) expect(scriptPreloads).toBeTruthy() - expect(scriptPreloads!.length).toBeGreaterThan(0) + expect(scriptPreloads.length).toBeGreaterThan(0) - for (const match of scriptPreloads!) { - const href = match.match(/href="([^"]+)"/)?.[1] + for (const match of scriptPreloads) { + const href = match[1] expect(href).toBeTruthy() expect(href).toMatch(/^http:\/\/localhost:\d+\//) } diff --git a/packages/start-plugin-core/src/import-protection/virtualModules.ts b/packages/start-plugin-core/src/import-protection/virtualModules.ts index 948f06fddb..29b6878927 100644 --- a/packages/start-plugin-core/src/import-protection/virtualModules.ts +++ b/packages/start-plugin-core/src/import-protection/virtualModules.ts @@ -104,21 +104,19 @@ function __report(action, accessPath) { ` : '' - const diagGetTraps = hasDiag - ? ` + const primitiveGetTraps = ` if (prop === Symbol.toPrimitive) { return () => { - __report('toPrimitive', name); + ${hasDiag ? `__report('toPrimitive', name);` : ''} return '[import-protection mock]'; }; } if (prop === 'toString' || prop === 'valueOf' || prop === 'toJSON') { return () => { - __report(String(prop), name); + ${hasDiag ? `__report(String(prop), name);` : ''} return '[import-protection mock]'; }; }` - : '' const applyBody = hasDiag ? `__report('call', name + '()'); @@ -151,7 +149,7 @@ function ${fnName}(name) { if (prop === 'caller') return null; if (prop === 'then') return (f) => Promise.resolve(f(proxy)); if (prop === 'catch') return () => Promise.resolve(proxy); - if (prop === 'finally') return (f) => { f(); return Promise.resolve(proxy); };${diagGetTraps} + if (prop === 'finally') return (f) => { f(); return Promise.resolve(proxy); };${primitiveGetTraps} if (typeof prop === 'symbol') return undefined; if (!(prop in children)) { children[prop] = ${fnName}(name + '.' + prop); diff --git a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts index 10c5783259..2ac513fb7d 100644 --- a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts +++ b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts @@ -14,6 +14,26 @@ import { } from '../../src/vite/import-protection-plugin/virtualModules' import type { ViolationInfo } from '../../src/import-protection/trace' +type MockValue = { + toString: () => string + valueOf: () => string + toJSON: () => string +} + +type MockFunction = { + (): MockValue +} + +function evaluateGeneratedModule(code: string): T { + const exports: Record = {} + const runnableCode = code + .replace(/export default mock;?/g, 'exports.default = mock;') + .replace(/export const ([A-Za-z_$][\w$]*) = ([^;]+);/g, 'exports.$1 = $2;') + + new Function('exports', runnableCode)(exports) + return exports as T +} + describe('loadSilentMockModule', () => { test('returns mock code', () => { const result = loadSilentMockModule() @@ -23,6 +43,17 @@ describe('loadSilentMockModule', () => { expect(result.code).toContain('@__NO_SIDE_EFFECTS__') expect(result.code).toContain('@__PURE__') }) + + test('supports primitive conversion for called mocks', async () => { + const result = loadSilentMockModule() + const mod = evaluateGeneratedModule<{ default: MockFunction }>(result.code) + const value = mod.default() + + expect(String(value)).toBe('[import-protection mock]') + expect(value.toString()).toBe('[import-protection mock]') + expect(value.valueOf()).toBe('[import-protection mock]') + expect(JSON.stringify(value)).toBe('"[import-protection mock]"') + }) }) describe('loadMarkerModule', () => { @@ -268,7 +299,16 @@ describe('generateSelfContainedMockModule', () => { test('is self-contained (no imports)', () => { const result = generateSelfContainedMockModule(['foo']) // Should not import from any other module - expect(result.code).not.toMatch(/\bimport\b/) + expect(result.code).not.toMatch(/^\s*import\s/m) + }) + + test('supports primitive conversion for named export calls', async () => { + const result = generateSelfContainedMockModule(['getSecret']) + const mod = evaluateGeneratedModule<{ getSecret: MockFunction }>( + result.code, + ) + + expect(String(mod.getSecret())).toBe('[import-protection mock]') }) }) From 53498e898724d517ff4074877b7c855a2064cd49 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 24 May 2026 01:46:03 +0200 Subject: [PATCH 10/10] fix --- .../tests/importProtection/virtualModules.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts index 2ac513fb7d..70f174650c 100644 --- a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts +++ b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts @@ -44,7 +44,7 @@ describe('loadSilentMockModule', () => { expect(result.code).toContain('@__PURE__') }) - test('supports primitive conversion for called mocks', async () => { + test('supports primitive conversion for called mocks', () => { const result = loadSilentMockModule() const mod = evaluateGeneratedModule<{ default: MockFunction }>(result.code) const value = mod.default() @@ -302,7 +302,7 @@ describe('generateSelfContainedMockModule', () => { expect(result.code).not.toMatch(/^\s*import\s/m) }) - test('supports primitive conversion for named export calls', async () => { + test('supports primitive conversion for named export calls', () => { const result = generateSelfContainedMockModule(['getSecret']) const mod = evaluateGeneratedModule<{ getSecret: MockFunction }>( result.code,