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/.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/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(
+ /
+ return (
+
+ )
default:
return null
}
@@ -106,9 +134,11 @@ function InlineCssStyle({
function Script({
attrs,
children,
+ preventScriptHoist,
}: {
attrs?: ScriptAttrs
children?: string
+ preventScriptHoist?: boolean
}) {
const router = useRouter()
const hydrated = useHydrated()
@@ -141,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') {
@@ -178,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
@@ -228,7 +222,20 @@ function Script({
// --- Server rendering ---
if (isServer ?? router.isServer) {
if (attrs?.src) {
- return
+ if (!preventScriptHoist) {
+ return
+ }
+
+ 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,61 +480,36 @@ 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
- // 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 &&
- routeManifest.assets &&
- routeManifest.assets.length > 0
- ) {
- nextFilteredRoutes[routeId] = {
- assets: routeManifest.assets,
- }
- }
- }
-
- filteredRoutes = stripInlinedStylesheetAssets(
- manifest,
- nextFilteredRoutes,
- matchesToDehydrate,
- )
-
- if (isProd) {
- getManifestCache(manifest).set(manifestCacheKey, filteredRoutes)
- }
- }
+ const cacheKey = getMatchedRoutesCacheKey(matchesToDehydrate)
+ const preparedManifest = getPreparedMatchedManifestRoutes(
+ manifest,
+ matchesToDehydrate,
+ cacheKey,
+ )
manifestToDehydrate = {
- routes: { ...filteredRoutes },
+ ...(manifest.scriptFormat
+ ? { scriptFormat: manifest.scriptFormat }
+ : {}),
+ ...(preparedManifest.inlineCssHrefs
+ ? { inlineStyle: createInlineCssPlaceholderAsset() }
+ : {}),
+ routes: preparedManifest.routes,
}
// 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 = {
+ ...manifestToDehydrate.routes,
+ [rootRouteId]: mergeRequestAssetsIntoRootRoute(
+ existingRoot,
+ requestAssets,
+ ),
}
}
}
diff --git a/packages/router-core/tests/hydrate.test.ts b/packages/router-core/tests/hydrate.test.ts
index d21445ada6..efac230e49 100644
--- a/packages/router-core/tests/hydrate.test.ts
+++ b/packages/router-core/tests/hydrate.test.ts
@@ -7,6 +7,11 @@ import { dehydrateSsrMatchId } from '../src/ssr/ssr-match-id'
import type { LocationRewrite } from '../src'
import type { TsrSsrGlobal } from '../src/ssr/types'
import type { AnyRouteMatch } from '../src'
+import type { Manifest } from '../src/manifest'
+
+const testManifest: Manifest = {
+ routes: {},
+}
describe('hydrate', () => {
let mockWindow: { $_TSR?: TsrSsrGlobal }
@@ -93,7 +98,7 @@ describe('hydrate', () => {
const mockBuffer = [vi.fn(), vi.fn()]
mockWindow.$_TSR = {
router: {
- manifest: { routes: {} },
+ manifest: testManifest,
dehydratedData: {},
lastMatchId: '/',
matches: [],
@@ -122,7 +127,7 @@ describe('hydrate', () => {
mockWindow.$_TSR = {
router: {
- manifest: { routes: {} },
+ manifest: testManifest,
dehydratedData: {},
lastMatchId: '/',
matches: [],
@@ -142,7 +147,6 @@ describe('hydrate', () => {
})
it('should set manifest in router.ssr', async () => {
- const testManifest = { routes: {} }
mockWindow.$_TSR = {
router: {
manifest: testManifest,
@@ -198,7 +202,7 @@ describe('hydrate', () => {
mockWindow.$_TSR = {
router: {
- manifest: { routes: {} },
+ manifest: testManifest,
dehydratedData: {},
lastMatchId: '/',
matches: dehydratedMatches,
@@ -246,7 +250,7 @@ describe('hydrate', () => {
mockWindow.$_TSR = {
router: {
- manifest: { routes: {} },
+ manifest: testManifest,
dehydratedData: {},
lastMatchId: '/',
matches: dehydratedMatches,
@@ -289,7 +293,7 @@ describe('hydrate', () => {
mockWindow.$_TSR = {
router: {
- manifest: { routes: {} },
+ manifest: testManifest,
dehydratedData: {},
lastMatchId: '/',
matches: dehydratedMatches,
@@ -333,7 +337,7 @@ describe('hydrate', () => {
mockWindow.$_TSR = {
router: {
- manifest: { routes: {} },
+ manifest: testManifest,
dehydratedData: {},
lastMatchId: '/',
matches: dehydratedMatches,
@@ -369,7 +373,7 @@ describe('hydrate', () => {
mockWindow.$_TSR = {
router: {
- manifest: { routes: {} },
+ manifest: testManifest,
dehydratedData: {},
lastMatchId: dehydrateSsrMatchId('/'),
matches: [
@@ -428,7 +432,7 @@ describe('hydrate', () => {
mockWindow.$_TSR = {
router: {
- manifest: { routes: {} },
+ manifest: testManifest,
dehydratedData: { rewrite: true },
lastMatchId: '/internal/internal',
matches: [
@@ -478,7 +482,7 @@ describe('hydrate', () => {
mockWindow.$_TSR = {
router: {
- manifest: { routes: {} },
+ manifest: testManifest,
dehydratedData: {},
lastMatchId: '/',
matches: [
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 b566f13dc8..f76535622f 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,
@@ -70,16 +67,13 @@ function buildInlineManifest(): Manifest {
}
}
-async function dehydrateManifest(options?: {
- includeUnmatchedRouteAssets?: boolean
-}) {
+async function dehydrateManifest() {
const router = buildRouter()
const manifest = buildManifest()
attachRouterServerSsrUtils({
router,
manifest,
- includeUnmatchedRouteAssets: options?.includeUnmatchedRouteAssets,
})
await router.load()
@@ -110,30 +104,192 @@ 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({
- assets: [
+ expect(manifest.routes['/posts']).toBeUndefined()
+ expect(manifest.routes['/']?.preloads).toEqual(['/assets/index.js'])
+ })
+
+ test('preserves script format when dehydrating the manifest', async () => {
+ const router = buildRouter()
+ const manifest: ServerManifest = {
+ ...buildManifest(),
+ scriptFormat: 'iife',
+ }
+
+ attachRouterServerSsrUtils({
+ router,
+ manifest,
+ })
+
+ await router.load()
+ await router.serverSsr!.dehydrate()
+
+ const script = router.serverSsr!.takeBufferedScripts()
+ expect(script?.children).toBeTruthy()
+ 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: [
{
- tag: 'link',
attrs: {
- rel: 'stylesheet',
- href: '/assets/shared.css',
- type: 'text/css',
+ src: '/assets/request-script.js',
+ type: 'module',
},
+ children: 'console.log("request")',
},
],
+ css: [
+ { href: '/assets/rsc-client.css', crossOrigin: 'use-credentials' },
+ '/assets/shared.css',
+ ],
})
})
- test('omits unmatched route assets when disabled', async () => {
- const manifest = await dehydrateManifest({
- includeUnmatchedRouteAssets: false,
+ 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,
})
- expect(manifest.routes['/posts']).toBeUndefined()
- expect(manifest.routes['/']?.preloads).toEqual(['/assets/index.js'])
+ 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 () => {
@@ -143,20 +299,14 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => {
attachRouterServerSsrUtils({
router,
manifest,
- includeUnmatchedRouteAssets: false,
})
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()
@@ -164,28 +314,91 @@ 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([
'/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,
+ })
+
+ 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/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 188bd5e187..4c1ece99c6 100644
--- a/packages/solid-router/src/Scripts.tsx
+++ b/packages/solid-router/src/Scripts.tsx
@@ -1,13 +1,14 @@
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 activeMatches = Solid.createMemo(() => router.stores.matches.get())
- const assetScripts = Solid.createMemo(() => {
+
+ const getAssetScripts = (matches: Array) => {
const assetScripts: Array = []
const manifest = router.ssr?.manifest
@@ -15,57 +16,67 @@ export const Scripts = () => {
return []
}
- activeMatches()
- .map((match) => router.looseRoutesById[match.routeId]!)
- .forEach((route) =>
- manifest.routes[route.id]?.assets
- ?.filter((d) => d.tag === 'script')
- .forEach((asset) => {
- assetScripts.push({
- tag: 'script',
- attrs: { ...asset.attrs, nonce },
- children: asset.children,
- } as any)
- }),
- )
+ for (const match of matches) {
+ const scripts = manifest.routes[match.routeId]?.scripts
+
+ if (!scripts) {
+ continue
+ }
+
+ for (const asset of scripts) {
+ assetScripts.push({
+ tag: 'script',
+ attrs: { ...asset.attrs, nonce },
+ children: asset.children,
+ })
+ }
+ }
return assetScripts
- })
+ }
- const scripts = Solid.createMemo(() =>
+ const getScripts = (matches: Array): Array =>
(
- activeMatches()
+ matches
.map((match) => match.scripts!)
.flat(1)
.filter(Boolean) as Array
- ).map(({ children, ...script }) => ({
- tag: 'script',
- attrs: {
- ...script,
- nonce,
- },
- children,
- })),
- )
+ ).map(
+ ({ children, ...script }) =>
+ ({
+ tag: 'script',
+ attrs: {
+ ...script,
+ nonce,
+ },
+ children,
+ }) satisfies RouterManagedTag,
+ )
- let serverBufferedScript: RouterManagedTag | undefined = undefined
+ const activeMatches = Solid.createMemo(() => router.stores.matches.get())
+ const assetScripts = Solid.createMemo(() => getAssetScripts(activeMatches()))
+ const scripts = Solid.createMemo(() => getScripts(activeMatches()))
- if (router.serverSsr) {
- serverBufferedScript = router.serverSsr.takeBufferedScripts()
- }
+ return renderScripts(router, scripts(), assetScripts())
+}
- const allScripts = [
- ...scripts(),
- ...assetScripts(),
- ] as Array
+function renderScripts(
+ router: ReturnType,
+ scripts: Array,
+ assetScripts: Array,
+) {
+ const allScripts = [...scripts, ...assetScripts] as Array
- if (serverBufferedScript) {
- allScripts.unshift(serverBufferedScript)
+ if ((isServer ?? router.isServer) && router.serverSsr) {
+ const serverBufferedScript = router.serverSsr.takeBufferedScripts()
+ if (serverBufferedScript) {
+ allScripts.unshift(serverBufferedScript)
+ }
}
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 da77c86191..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,
- isInlinableStylesheet,
- resolveManifestAssetLink,
+ getScriptPreloadAttrs,
+ 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,125 +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) => {
- const preloadLink = resolveManifestAssetLink(preload)
- preloadLinks.push({
- tag: 'link',
- attrs: {
- rel: 'modulepreload',
- href: preloadLink.href,
- crossOrigin:
- getAssetCrossOrigin(assetCrossOrigin, 'modulepreload') ??
- preloadLink.crossOrigin,
- 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
}
@@ -262,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 29f3138352..b15faaef19 100644
--- a/packages/solid-router/tests/Scripts.test.tsx
+++ b/packages/solid-router/tests/Scripts.test.tsx
@@ -21,20 +21,16 @@ import {
import { Scripts } from '../src/Scripts'
import type { Manifest } from '@tanstack/router-core'
-const createTestManifest = (routeId: string) =>
+const createTestManifest = (
+ routeId: string,
+ options?: { scriptFormat?: Manifest['scriptFormat'] },
+) =>
({
+ ...(options?.scriptFormat ? { scriptFormat: options.scriptFormat } : {}),
routes: {
[routeId]: {
preloads: ['/main.js'],
- assets: [
- {
- tag: 'link',
- attrs: {
- rel: 'stylesheet',
- href: '/main.css',
- },
- },
- ],
+ css: ['/main.css'],
},
},
}) satisfies Manifest
@@ -259,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: [],
},
},
},
@@ -314,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({
@@ -323,7 +309,7 @@ describe('ssr scripts', () => {
<>
@@ -370,6 +356,100 @@ describe('ssr scripts', () => {
?.getAttribute('crossorigin'),
).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()
+
+ const rootRoute = createRootRoute({
+ component: () => {
+ return (
+ <>
+
+
+ >
+ )
+ },
+ })
+
+ const indexRoute = createRoute({
+ path: '/',
+ getParentRoute: () => rootRoute,
+ component: () => Index
,
+ })
+
+ const router = createRouter({
+ history,
+ routeTree: rootRoute.addChildren([indexRoute]),
+ })
+
+ router.ssr = {
+ manifest: createTestManifest(rootRoute.id, { scriptFormat: 'iife' }),
+ }
+
+ await router.load()
+
+ render(() => )
+
+ await waitFor(() => {
+ expect(
+ document.head.querySelector('link[rel="preload"][as="script"]'),
+ ).toBeTruthy()
+ })
+
+ expect(document.head.querySelector('link[rel="modulepreload"]')).toBeFalsy()
+ })
})
describe('ssr HeadContent', () => {
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/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/planning.ts b/packages/start-plugin-core/src/rsbuild/planning.ts
index 5ab3b631f2..856ea23240 100644
--- a/packages/start-plugin-core/src/rsbuild/planning.ts
+++ b/packages/start-plugin-core/src/rsbuild/planning.ts
@@ -5,6 +5,7 @@ import { ENTRY_POINTS } from '../constants'
import type { EnvironmentConfig } from '@rsbuild/core'
import type { ResolvedStartEntryPlan } from '../planning'
import type { RsbuildEnvironmentOverrides } from './types'
+import type { ScriptFormat } from '@tanstack/router-core'
const require = createRequire(import.meta.url)
@@ -72,6 +73,7 @@ export function createRsbuildEnvironmentPlan(opts: {
publicBase: string
serverFnProviderEnv: string
environmentOverrides?: RsbuildEnvironmentOverrides
+ scriptFormat?: ScriptFormat
rsc?: boolean | undefined
dev?: boolean | undefined
}): RsbuildEnvironmentPlanResult {
@@ -87,6 +89,20 @@ export function createRsbuildEnvironmentPlan(opts: {
: {}),
}
const environmentOverrides = opts.environmentOverrides ?? {}
+ const scriptFormat = opts.scriptFormat ?? 'module'
+ const clientOutputModule = scriptFormat === 'module'
+ const userClientOutputModule =
+ environmentOverrides.client?.output?.module ??
+ environmentOverrides.all?.output?.module
+
+ if (
+ typeof userClientOutputModule === 'boolean' &&
+ userClientOutputModule !== clientOutputModule
+ ) {
+ throw new Error(
+ 'TanStack Start rsbuild.client.output controls environments.client.output.module. Remove environments.client.output.module or set rsbuild.client.output to match it.',
+ )
+ }
return {
environments: {
@@ -102,7 +118,7 @@ export function createRsbuildEnvironmentPlan(opts: {
},
output: {
target: 'web',
- module: true,
+ module: clientOutputModule,
distPath: {
root: opts.clientOutputDirectory,
},
@@ -224,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 242cb549ae..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,
@@ -10,7 +9,7 @@ import {
} from '../config-context'
import { normalizePath } from '../utils'
import { createServerFnBasePath, normalizePublicBase } from '../planning'
-import { parseStartConfig } from './schema'
+import { parseStartConfig, rsbuildClientOutputSchema } from './schema'
import {
RSBUILD_ENVIRONMENT_NAMES,
RSBUILD_RSC_LAYERS,
@@ -40,6 +39,7 @@ import type {
rspack as rspackNamespaceType,
} from '@rsbuild/core'
import type { TanStackStartRsbuildInputConfig } from './schema'
+import type { ScriptFormat } from '@tanstack/router-core'
// Detect whether this plugin source is running from inside the TanStack
// Router monorepo (packages/start-plugin-core/src/rsbuild/plugin.ts). When
@@ -62,6 +62,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,
@@ -78,6 +79,9 @@ export function tanStackStartRsbuild(
const { getConfig, resolvedStartConfig } = configContext
const serverFnProviderEnv = corePluginOpts.providerEnvironmentName
const ssrIsProvider = corePluginOpts.ssrIsProvider
+ const scriptFormat = rsbuildClientOutputSchema.parse(
+ startPluginOpts.rsbuild?.client?.output ?? 'module',
+ ) satisfies ScriptFormat
// RSC plugin instances — created lazily when rspack namespace is available
let rscPlugins: RscPluginPair | undefined
@@ -90,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
// ---------------------------------------------------------------
@@ -155,6 +171,7 @@ export function tanStackStartRsbuild(
publicBase: resolvedStartConfig.basePaths.publicBase,
serverFnProviderEnv,
environmentOverrides: corePluginOpts.rsbuild?.environments,
+ scriptFormat,
rsc: rscOpts,
dev: isDev,
})
@@ -252,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,
@@ -266,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,
})
// ---------------------------------------------------------------
@@ -290,9 +301,41 @@ export function tanStackStartRsbuild(
getDevClientEntryUrl: (publicBase: string) =>
joinURL(publicBase, 'static/js/index.js'),
rscEnabled,
+ scriptFormat,
})
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
// ---------------------------------------------------------------
@@ -484,10 +527,6 @@ export function tanStackStartRsbuild(
stage: -10,
},
async (compilation: RspackCompilationExtended) => {
- if (!hasKeys(serverFnsById)) {
- return
- }
-
const resolverContent =
virtualModuleState.generateCurrentResolverContent(true)
virtualModuleState.tryUpdateServerFnResolver(
@@ -579,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)
+ }
}
}
})
@@ -620,18 +669,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) {
@@ -720,25 +783,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/schema.ts b/packages/start-plugin-core/src/rsbuild/schema.ts
index adbc5c5328..86194e309c 100644
--- a/packages/start-plugin-core/src/rsbuild/schema.ts
+++ b/packages/start-plugin-core/src/rsbuild/schema.ts
@@ -6,11 +6,21 @@ import {
import type { CompileStartFrameworkOptions } from '../types'
import type { InlineCssInputOptions } from '../schema'
+export const rsbuildClientOutputSchema = z.enum(['module', 'iife'])
+
export const tanstackStartRsbuildOptionsSchema =
tanstackStartOptionsObjectSchema
.extend({
rsbuild: z
- .object({ installDevServerMiddleware: z.boolean().optional() })
+ .object({
+ installDevServerMiddleware: z.boolean().optional(),
+ client: z
+ .object({
+ output: rsbuildClientOutputSchema.optional().default('module'),
+ })
+ .optional()
+ .prefault({}),
+ })
.optional(),
})
.optional()
@@ -30,6 +40,12 @@ export function parseStartConfig(
export type TanStackStartRsbuildInputConfig = z.input<
typeof tanstackStartRsbuildOptionsSchema
> & {
+ rsbuild?: {
+ installDevServerMiddleware?: boolean
+ client?: {
+ output?: z.input
+ }
+ }
server?: {
build?: {
inlineCss?: InlineCssInputOptions
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..5cf9319238 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,55 @@ 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
- ? opts.compilerTransforms
- : undefined
+ 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 +251,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 +357,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 +391,59 @@ 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..dfa6119246
--- /dev/null
+++ b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts
@@ -0,0 +1,20 @@
+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/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts
index 766141d487..056a3e9bfc 100644
--- a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts
+++ b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts
@@ -14,6 +14,7 @@ import type {
} from '../types'
import type { InlineCssOptions } from '../start-manifest-plugin/manifestBuilder'
import type { ServerFn } from '../start-compiler/types'
+import type { ScriptFormat } from '@tanstack/router-core'
type RspackNamespace = typeof rspackNamespaceType
type RspackVirtualModulesPlugin = InstanceType<
@@ -66,9 +67,17 @@ export interface VirtualModuleState {
// Manifest module codegen
// ---------------------------------------------------------------------------
-function generateManifestModuleDev(devClientEntryUrl: string): string {
+function getScriptFormatProperty(scriptFormat: ScriptFormat): string {
+ return scriptFormat === 'iife' ? ` scriptFormat: 'iife',\n` : ''
+}
+
+function generateManifestModuleDev(
+ devClientEntryUrl: string,
+ scriptFormat: ScriptFormat,
+): string {
+ const scriptFormatProperty = getScriptFormatProperty(scriptFormat)
return `const fallbackManifest = {
- routes: {},
+${scriptFormatProperty} routes: {},
clientEntry: '${devClientEntryUrl}',
}
export const tsrStartManifest = () => globalThis[${JSON.stringify(DEV_START_MANIFEST_GLOBAL)}] ?? fallbackManifest`
@@ -78,6 +87,7 @@ function buildStartManifestData(
clientBuild: NormalizedClientBuild,
publicBase: string,
inlineCss: InlineCssOptions,
+ scriptFormat: ScriptFormat,
) {
const routeTreeRoutes = globalThis.TSS_ROUTES_MANIFEST
return buildStartManifest({
@@ -85,6 +95,7 @@ function buildStartManifestData(
routeTreeRoutes,
basePath: publicBase,
inlineCss,
+ scriptFormat,
})
}
@@ -92,9 +103,10 @@ function serializeStartManifestData(
clientBuild: NormalizedClientBuild,
publicBase: string,
inlineCss: InlineCssOptions,
+ scriptFormat: ScriptFormat,
): string {
return JSON.stringify(
- buildStartManifestData(clientBuild, publicBase, inlineCss),
+ buildStartManifestData(clientBuild, publicBase, inlineCss, scriptFormat),
)
}
@@ -103,13 +115,14 @@ function generateManifestModuleBuild(
publicBase: string,
_devClientEntryUrl: string,
inlineCss: InlineCssOptions,
+ scriptFormat: ScriptFormat,
): string {
if (!clientBuild) {
return `const tsrStartManifestData = ${JSON.stringify(START_MANIFEST_PLACEHOLDER)}
export const tsrStartManifest = () => tsrStartManifestData`
}
- return `export const tsrStartManifest = () => (${serializeStartManifestData(clientBuild, publicBase, inlineCss)})`
+ return `export const tsrStartManifest = () => (${serializeStartManifestData(clientBuild, publicBase, inlineCss, scriptFormat)})`
}
// ---------------------------------------------------------------------------
@@ -220,6 +233,7 @@ export interface RegisterVirtualModulesOptions {
getDevClientEntryUrl: (publicBase: string) => string
/** Whether RSC virtual modules should be registered. */
rscEnabled?: boolean | undefined
+ scriptFormat: ScriptFormat
}
/**
@@ -363,12 +377,13 @@ export function registerVirtualModules(
resolvedStartConfig.basePaths.publicBase,
)
content[paths.manifest] = isDev
- ? generateManifestModuleDev(devClientEntryUrl)
+ ? generateManifestModuleDev(devClientEntryUrl, opts.scriptFormat)
: generateManifestModuleBuild(
clientBuild,
resolvedStartConfig.basePaths.publicBase,
devClientEntryUrl,
startConfig.server.build.inlineCss,
+ opts.scriptFormat,
)
} else {
content[paths.manifest] = 'export default {}'
@@ -514,6 +529,7 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is
!isDev
? startConfig.server.build.inlineCss
: { enabled: false, transformAssets: false },
+ opts.scriptFormat,
)
},
@@ -527,6 +543,7 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is
!isDev
? startConfig.server.build.inlineCss
: { enabled: false, transformAssets: false },
+ opts.scriptFormat,
)
},
@@ -543,17 +560,15 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is
clientBuild,
resolvedStartConfig.basePaths.publicBase,
{ enabled: false, transformAssets: false },
+ opts.scriptFormat,
)
}
},
updateServerFnResolver() {
- for (const environmentName of new Set([
- RSBUILD_ENVIRONMENT_NAMES.server,
- ...(hasSeparateProviderEnvironment ? [opts.providerEnvName] : []),
- ])) {
+ const updateEnvironment = (environmentName: string) => {
if (!needsServerFnResolver(environmentName)) {
- continue
+ return
}
writeResolverContent(
@@ -561,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 6f27f37d4d..a07237fe03 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 {
@@ -12,7 +13,12 @@ import {
normalizeViteClientChunk,
} from '../vite/start-manifest-plugin/normalized-client-build'
import { processInlineCssUrls } from './inlineCss'
-import type { ManifestAssetLink, RouterManagedTag } from '@tanstack/router-core'
+import type {
+ ManifestAssetLink,
+ ManifestCssLink,
+ ManifestScript,
+ ScriptFormat,
+} from '@tanstack/router-core'
import type { InlineCssTemplate } from './inlineCss'
import type { NormalizedClientBuild, NormalizedClientChunk } from '../types'
@@ -21,12 +27,15 @@ 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
@@ -36,16 +45,18 @@ 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
}
export interface StartManifest {
+ scriptFormat?: ScriptFormat
routes: Record
clientEntry: string
inlineCss?: {
@@ -92,12 +103,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
}
@@ -106,11 +115,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
}
@@ -119,21 +128,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
}
@@ -150,26 +163,63 @@ 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
basePath: string
inlineCss?: InlineCssOptions
+ scriptFormat?: ScriptFormat
additionalRouteAssets?: Partial<
- Record>
+ Record>
>
}): StartManifest {
const scannedChunks = scanClientChunks(options.clientBuild)
@@ -186,12 +236,13 @@ export function buildStartManifest(options: {
dedupeNestedRouteManifestEntries(rootRouteId, routes[rootRouteId]!, routes)
- // Prune routes with no assets or preloads from the manifest
- for (const routeId of Object.keys(routes)) {
+ // Prune routes with no manifest data
+ for (const routeId in 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]
}
}
@@ -201,6 +252,10 @@ export function buildStartManifest(options: {
clientEntry: assetResolvers.getAssetPath(scannedChunks.entryChunk.fileName),
}
+ if (options.scriptFormat === 'iife') {
+ result.scriptFormat = 'iife'
+ }
+
if (options.inlineCss?.enabled) {
result.inlineCss = buildInlineCssManifestData({
routes,
@@ -254,7 +309,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) => {
@@ -268,24 +323,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) => {
@@ -307,39 +355,36 @@ 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<
- NormalizedClientChunk,
- Array
- >()
+ const linksByChunk = new Map>()
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) {
@@ -347,8 +392,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]!)
@@ -356,19 +401,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 }
@@ -383,11 +428,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))
}
}
@@ -403,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) {
@@ -420,6 +462,7 @@ function buildInlineCssManifestData(options: {
styles[cssHref] = result.css
if (result.template) {
+ templates ||= {}
templates[cssHref] = result.template
}
missingHrefs.delete(cssHref)
@@ -435,7 +478,7 @@ function buildInlineCssManifestData(options: {
return {
styles,
- ...(Object.keys(templates).length ? { templates } : {}),
+ ...(templates ? { templates } : {}),
}
}
@@ -449,13 +492,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)) {
@@ -535,7 +578,7 @@ export function buildRouteManifestRoutes(options: {
}
const route = (routes[routeId] = routes[routeId] || {})
- route.assets = appendUniqueAssets(route.assets, [...assets])
+ appendAdditionalRouteEntries(route, assets)
}
}
@@ -546,19 +589,18 @@ 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)
@@ -569,7 +611,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) {
@@ -601,10 +645,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
@@ -633,30 +679,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
+ }
}
}
@@ -675,7 +756,8 @@ function dedupeNestedRouteManifestEntries(
childRoute,
routesById,
seenPreloads,
- seenAssets,
+ seenScripts,
+ seenStylesheets,
)
}
}
@@ -686,9 +768,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/importProtection/virtualModules.test.ts b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts
index 10c5783259..70f174650c 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', () => {
+ 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', () => {
+ const result = generateSelfContainedMockModule(['getSecret'])
+ const mod = evaluateGeneratedModule<{ getSecret: MockFunction }>(
+ result.code,
+ )
+
+ expect(String(mod.getSecret())).toBe('[import-protection mock]')
})
})
diff --git a/packages/start-plugin-core/tests/rsbuild-output-directory.test.ts b/packages/start-plugin-core/tests/rsbuild-output-directory.test.ts
index 7d5414f22c..90e33fd019 100644
--- a/packages/start-plugin-core/tests/rsbuild-output-directory.test.ts
+++ b/packages/start-plugin-core/tests/rsbuild-output-directory.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, test } from 'vitest'
-import { resolveRsbuildOutputDirectory } from '../src/rsbuild/planning'
+import {
+ createRsbuildEnvironmentPlan,
+ resolveRsbuildOutputDirectory,
+} from '../src/rsbuild/planning'
describe('resolveRsbuildOutputDirectory', () => {
test('uses explicit environment distPath string', () => {
@@ -57,3 +60,77 @@ describe('resolveRsbuildOutputDirectory', () => {
).toBe('dist/client')
})
})
+
+describe('createRsbuildEnvironmentPlan client output', () => {
+ const baseOptions = {
+ root: '/app',
+ entryAliases: {
+ client: '/app/src/client.tsx',
+ server: '/app/src/server.ts',
+ start: '/app/src/start.ts',
+ router: '/app/src/router.tsx',
+ alias: {
+ 'virtual:tanstack-start-client-entry': '/app/src/client.tsx',
+ 'virtual:tanstack-start-server-entry': '/app/src/server.ts',
+ '#tanstack-start-entry': '/app/src/start.ts',
+ '#tanstack-router-entry': '/app/src/router.tsx',
+ },
+ },
+ clientOutputDirectory: 'dist/client',
+ serverOutputDirectory: 'dist/server',
+ publicBase: '/_build/',
+ serverFnProviderEnv: 'ssr',
+ }
+
+ test('sets client output.module from scriptFormat', () => {
+ expect(
+ createRsbuildEnvironmentPlan({
+ ...baseOptions,
+ scriptFormat: 'iife',
+ }).environments.client!.output?.module,
+ ).toBe(false)
+
+ expect(
+ createRsbuildEnvironmentPlan({
+ ...baseOptions,
+ scriptFormat: 'module',
+ }).environments.client!.output?.module,
+ ).toBe(true)
+ })
+
+ test('throws when client output.module conflicts with scriptFormat', () => {
+ expect(() =>
+ createRsbuildEnvironmentPlan({
+ ...baseOptions,
+ scriptFormat: 'iife',
+ environmentOverrides: {
+ client: {
+ output: {
+ module: true,
+ },
+ },
+ },
+ }),
+ ).toThrow(
+ 'TanStack Start rsbuild.client.output controls environments.client.output.module',
+ )
+ })
+
+ test('throws when shared output.module conflicts with scriptFormat', () => {
+ expect(() =>
+ createRsbuildEnvironmentPlan({
+ ...baseOptions,
+ scriptFormat: 'iife',
+ environmentOverrides: {
+ all: {
+ output: {
+ module: true,
+ },
+ },
+ },
+ }),
+ ).toThrow(
+ 'TanStack Start rsbuild.client.output controls environments.client.output.module',
+ )
+ })
+})
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 1b156c2af5..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
@@ -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,9 @@ 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 +732,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 +783,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 +825,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 +860,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 +899,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 +919,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 +938,7 @@ describe('buildStartManifest', () => {
).toEqual(manifest)
})
- test('serializeStartManifest handles manifests without route assets', () => {
+ test('serializeStartManifest handles manifests without route links', () => {
const manifest: StartManifest = {
routes: {
__root__: {
@@ -1087,10 +957,40 @@ describe('buildStartManifest', () => {
deserializeSerializedManifest(serializeStartManifest(manifest)),
).toEqual(manifest)
})
+
+ test('buildStartManifest includes scriptFormat only for iife output', () => {
+ const entryChunk = makeChunk({
+ fileName: 'entry.js',
+ isEntry: true,
+ })
+ const routeTreeRoutes = {
+ __root__: {
+ filePath: '/routes/__root.tsx',
+ },
+ }
+
+ expect(
+ buildStartManifest({
+ clientBuild: normalizeTestBuild({ 'entry.js': entryChunk }),
+ routeTreeRoutes,
+ basePath: '/assets',
+ scriptFormat: 'module',
+ }).scriptFormat,
+ ).toBeUndefined()
+
+ expect(
+ buildStartManifest({
+ clientBuild: normalizeTestBuild({ 'entry.js': entryChunk }),
+ routeTreeRoutes,
+ basePath: '/assets',
+ scriptFormat: 'iife',
+ }).scriptFormat,
+ ).toBe('iife')
+ })
})
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,
@@ -1149,65 +1049,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',
@@ -1267,37 +1132,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',
@@ -1376,65 +1220,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',
@@ -1526,7 +1335,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')
@@ -1573,7 +1382,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(
@@ -1586,7 +1395,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,
@@ -1604,7 +1413,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-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',
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/early-hints.ts b/packages/start-server-core/src/early-hints.ts
index 3504150929..f46cf5f587 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,
- resolveManifestAssetLink,
+ 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 = []
@@ -184,33 +185,32 @@ export function collectStaticHintsFromManifest(
if (!routeManifest) continue
for (const link of routeManifest.preloads ?? []) {
- const { href, crossOrigin } = resolveManifestAssetLink(link)
- const hint: EarlyHint = { href, rel: 'modulepreload', as: 'script' }
- if (crossOrigin !== undefined) hint.crossOrigin = crossOrigin
+ const attrs = getScriptPreloadAttrs(manifest, link)
+ const hint: EarlyHint = {
+ href: attrs.href,
+ rel: attrs.rel,
+ as: 'script',
+ }
+ if (attrs.crossOrigin !== undefined) hint.crossOrigin = attrs.crossOrigin
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 stylesheetHref = getStylesheetHref(link)
+ if (manifest.inlineCss?.styles[stylesheetHref] !== undefined) {
continue
}
+ const resolvedLink = resolveManifestCssLink(link)
- 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..a1edd4fd79 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,11 @@ 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 +208,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 +226,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 +244,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 +258,7 @@ async function resolveFinalManifest(opts: {
cache: boolean
inlineCss: boolean
finalManifestCache?: FinalManifestCache
-}): Promise {
+}): Promise {
const computeFinalManifest = async () => {
return buildFinalManifest({
base: await opts.getBaseManifest(),
@@ -284,7 +286,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 f15b689c7d..451decd6a8 100644
--- a/packages/start-server-core/src/router-manifest.ts
+++ b/packages/start-server-core/src/router-manifest.ts
@@ -1,16 +1,15 @@
-import { buildDevStylesUrl, rootRouteId } from '@tanstack/router-core'
-import type {
- AnyRoute,
- ManifestAssetLink,
- RouterManagedTag,
+import {
+ DEV_STYLES_ATTR,
+ buildDevStylesUrl,
+ rootRouteId,
} 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.
// 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,11 +27,16 @@ 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] =
- startManifest.routes[rootRouteId] || {})
-
- rootRoute.assets = rootRoute.assets || []
+ const updateRootRoute = (nextRootRoute: ServerManifestRoute) => {
+ rootRoute = nextRootRoute
+ routes = {
+ ...routes,
+ [rootRouteId]: rootRoute,
+ }
+ }
// Inject dev styles link in dev mode (when SSR styles are enabled)
if (
@@ -41,54 +45,66 @@ 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,
+ },
+ ],
})
}
- // Collect injected head scripts in dev mode (returned separately so we can
- // build the client entry script tag after URL transforms are applied)
- let injectedHeadScripts: string | undefined
if (process.env.TSS_DEV_SERVER === 'true') {
const mod = await import('tanstack-start-injected-head-scripts:v')
if (mod.injectedHeadScripts) {
- injectedHeadScripts = mod.injectedHeadScripts
+ updateRootRoute({
+ ...rootRoute,
+ scripts: [
+ ...(rootRoute?.scripts ?? []),
+ {
+ attrs: {
+ type: 'module',
+ },
+ children: mod.injectedHeadScripts,
+ },
+ ],
+ })
+ }
+ }
+
+ 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 = {
- inlineCss: startManifest.inlineCss,
- routes: Object.fromEntries(
- Object.entries(startManifest.routes).flatMap(([k, v]) => {
- const result = {} as {
- preloads?: Array
- assets?: Array
- }
- let hasData = false
- if (v.preloads && v.preloads.length > 0) {
- result['preloads'] = v.preloads
- hasData = true
- }
- if (v.assets && v.assets.length > 0) {
- result['assets'] = v.assets
- hasData = true
- }
- if (!hasData) {
- return []
- }
- return [[k, result]]
- }),
- ),
+ ...(startManifest.scriptFormat
+ ? { scriptFormat: startManifest.scriptFormat }
+ : {}),
+ ...(startManifest.inlineCss ? { inlineCss: startManifest.inlineCss } : {}),
+ routes: manifestRoutes,
}
return {
manifest: manifest as StartManifestWithClientEntry['manifest'],
clientEntry: startManifest.clientEntry,
- injectedHeadScripts,
}
}
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 2b387fbb7a..c2b03ad9e3 100644
--- a/packages/start-server-core/src/transformAssetUrls.ts
+++ b/packages/start-server-core/src/transformAssetUrls.ts
@@ -1,11 +1,17 @@
-import { resolveManifestAssetLink, rootRouteId } from '@tanstack/router-core'
+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 }
@@ -13,16 +19,12 @@ export type { AssetCrossOrigin }
export type TransformAssetsContext =
| {
url: string
- kind: 'modulepreload'
+ kind: 'script'
}
| {
url: string
kind: 'stylesheet'
}
- | {
- url: string
- kind: 'clientEntry'
- }
| {
url: string
kind: 'css-url'
@@ -33,7 +35,7 @@ export type TransformAssetKind = TransformAssetsContext['kind']
type TransformAssetsShorthandCrossOriginKind = Exclude<
TransformAssetKind,
- 'clientEntry' | 'css-url'
+ 'css-url'
>
export type TransformAssetResult =
@@ -114,7 +116,7 @@ export type TransformAssetsOptions =
* crossOrigin: 'anonymous'
*
* // Different values per kind
- * crossOrigin: { modulepreload: 'anonymous', stylesheet: 'use-credentials' }
+ * crossOrigin: { script: 'anonymous', stylesheet: 'use-credentials' }
* ```
*/
export type TransformAssetsCrossOriginConfig =
@@ -136,7 +138,7 @@ export interface TransformAssetsObjectShorthand {
/** URL prefix prepended to every asset URL. */
prefix: string
/**
- * Optional crossOrigin attribute applied to manifest-managed ` ` assets.
+ * Optional crossOrigin attribute applied to transformed script and stylesheet assets.
*
* Accepts a single value or a per-kind record.
*/
@@ -211,7 +213,7 @@ async function transformInlineCssTemplate(options: {
}
async function transformInlineCssStyles(
- inlineCss: NonNullable,
+ inlineCss: NonNullable,
transformFn: TransformAssetsFn,
) {
const transformedStyles: Record = {}
@@ -287,7 +289,7 @@ export function resolveTransformAssetsConfig(
transformFn: ({ url, kind }) => {
const href = `${prefix}${url}`
- if (kind === 'clientEntry' || kind === 'css-url') {
+ if (kind === 'css-url') {
return { href }
}
@@ -321,44 +323,104 @@ export function resolveTransformAssetsConfig(
}
export interface StartManifestWithClientEntry {
- manifest: Manifest
+ manifest: ServerManifest
clientEntry: string
- /** Script content prepended before the client entry import (dev only) */
- injectedHeadScripts?: string
}
/**
* Builds the client entry `