Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/gentle-pandas-clap.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions .changeset/slow-badgers-remember.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/router/guide/document-head-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,14 @@ The `<HeadContent />` component is **required** to render the head, title, meta,
It should be **rendered either in the `<head>` 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 `<head>` tag.

For manifest-managed assets, you can also set `crossorigin` values on emitted
`modulepreload` and stylesheet links:
script preload and stylesheet links:

```tsx
<HeadContent assetCrossOrigin="anonymous" />

<HeadContent
assetCrossOrigin={{
modulepreload: 'anonymous',
script: 'anonymous',
stylesheet: 'use-credentials',
}}
/>
Expand Down
33 changes: 16 additions & 17 deletions docs/start/framework/react/guide/cdn-asset-urls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

- `<link rel="modulepreload">` tags for JavaScript preloads
- JavaScript preload links (`<link rel="modulepreload">` for module output, or `<link rel="preload" as="script">` for IIFE output)
- `<link rel="stylesheet">` 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.
Expand Down Expand Up @@ -75,7 +75,7 @@ export default createServerEntry({ fetch: handler })
transformAssets: {
prefix: 'https://cdn.example.com',
crossOrigin: {
modulepreload: 'anonymous',
script: 'anonymous',
stylesheet: 'use-credentials',
},
}
Expand All @@ -94,13 +94,13 @@ or:
```tsx
<HeadContent
assetCrossOrigin={{
modulepreload: 'anonymous',
script: 'anonymous',
stylesheet: 'use-credentials',
}}
/>
```

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

Expand All @@ -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',
Expand All @@ -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.

Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.

Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/start/framework/react/guide/early-hints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions e2e/react-router/basic-file-based/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions e2e/react-start/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@
"toolchain": "rsbuild",
"mode": "ssr"
},
{
"toolchain": "rsbuild",
"mode": "ssr",
"name": "iife",
"env": {
"TSS_RSB_CLIENT_OUTPUT": "iife"
}
},
{
"toolchain": "rsbuild",
"mode": "spa"
Expand Down
3 changes: 3 additions & 0 deletions e2e/react-start/basic/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
: {}),
},
},

Expand Down
19 changes: 19 additions & 0 deletions e2e/react-start/basic/start-mode-config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -28,5 +40,12 @@ export function getStartModeConfig() {
maxRedirects: 100,
}
: undefined,
rsbuild: rsbuildClientOutput
? {
client: {
output: rsbuildClientOutput,
},
}
: undefined,
}
}
32 changes: 32 additions & 0 deletions e2e/react-start/basic/tests/client-output.spec.ts
Original file line number Diff line number Diff line change
@@ -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(/<link[^>]+rel="preload"[^>]+as="script"/)

const clientEntry = html.match(
/<script\b[^>]+src="([^"]*\/static\/js\/index[^"]*)"[^>]*>/,
)
expect(clientEntry).toBeTruthy()
expect(clientEntry![0]).toContain('async')
expect(clientEntry![0]).not.toContain('type="module"')

await expect(
page.getByRole('link', { name: 'sunt aut facere repe' }),
).toBeVisible()

await page.getByRole('link', { name: 'sunt aut facere repe' }).click()

await expect(page.getByRole('heading')).toContainText('sunt aut facere')
})
34 changes: 21 additions & 13 deletions e2e/react-start/csp/tests/csp.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { expect } from '@playwright/test'
import { expect, type Page } from '@playwright/test'
import { test } from '@tanstack/router-e2e-utils'

async function getRawHtml(page: Page) {
let rawHtml = ''

await page.route('/', async (route) => {
const response = await route.fetch()
rawHtml = await response.text()
await route.fulfill({ response, body: rawHtml })
})

const response = await page.goto('/')
await page.unrouteAll({ behavior: 'ignoreErrors' })

return { rawHtml, response }
}

test('CSP header is set with nonce', async ({ page }) => {
const response = await page.goto('/')
const csp = response?.headers()['content-security-policy']
Expand All @@ -21,9 +36,10 @@ test('Inline styles have nonce attribute', async ({ page }) => {
})

test('External script has nonce attribute', async ({ page }) => {
await page.goto('/')
const externalScript = page.locator('script[src="/external.js"]')
await expect(externalScript).toHaveAttribute('nonce')
const { rawHtml } = await getRawHtml(page)
expect(rawHtml).toMatch(
/<script(?=[^>]*\bsrc="\/external\.js")(?=[^>]*\bnonce="[^"]+")[^>]*>/,
)
})

test('External stylesheet has nonce attribute', async ({ page }) => {
Expand All @@ -34,15 +50,7 @@ test('External stylesheet has nonce attribute', async ({ page }) => {

test('Nonces match between header and elements', async ({ page }) => {
// Intercept the HTML response to get raw content before browser strips nonces
let rawHtml = ''
await page.route('/', async (route) => {
const response = await route.fetch()
rawHtml = await response.text()
await route.fulfill({ response })
})

const response = await page.goto('/')
await page.unrouteAll({ behavior: 'ignoreErrors' })
const { rawHtml, response } = await getRawHtml(page)

const csp = response?.headers()['content-security-policy'] || ''

Expand Down
1 change: 1 addition & 0 deletions e2e/react-start/dev-ssr-styles/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@tanstack/router-core": "workspace:*",
"@tanstack/router-e2e-utils": "workspace:*",
"@types/node": "^22.10.2",
"@types/react": "^19.0.8",
Expand Down
13 changes: 6 additions & 7 deletions e2e/react-start/dev-ssr-styles/tests/app.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import { DEV_STYLES_ATTR } from '@tanstack/router-core'
import { test } from '@tanstack/router-e2e-utils'
import { ssrStylesMode } from '../env'

Expand Down Expand Up @@ -29,9 +30,9 @@ test.describe(`dev.ssrStyles (mode=${ssrStylesMode})`, () => {
expect(response.ok()).toBeTruthy()
const html = await response.text()

// Should have a link tag with data-tanstack-router-dev-styles
expect(html).toContain('data-tanstack-router-dev-styles')
// Should have a dev styles link tag.
expect(html).toContain('/@tanstack-start/styles.css')
expect(html).toContain(DEV_STYLES_ATTR)
})

test('dev styles link uses vite base (/) as basepath prefix', async ({
Expand Down Expand Up @@ -75,9 +76,9 @@ test.describe(`dev.ssrStyles (mode=${ssrStylesMode})`, () => {
expect(response.ok()).toBeTruthy()
const html = await response.text()

// Should NOT have a link tag with data-tanstack-router-dev-styles
expect(html).not.toContain('data-tanstack-router-dev-styles')
// Should NOT have a dev styles link tag.
expect(html).not.toContain('/@tanstack-start/styles.css')
expect(html).not.toContain(DEV_STYLES_ATTR)
})

test('page still renders without dev styles', async ({ page }) => {
Expand All @@ -100,16 +101,14 @@ test.describe(`dev.ssrStyles (mode=${ssrStylesMode})`, () => {
expect(response.ok()).toBeTruthy()
const html = await response.text()

// Should have a link tag with data-tanstack-router-dev-styles
expect(html).toContain('data-tanstack-router-dev-styles')

// The dev styles URL should use /custom-styles/ as the basepath prefix
const match = html.match(
/href="([^"]*@tanstack-start\/styles\.css[^"]*)"/,
)
expect(match).toBeTruthy()
const href = match![1]
expect(href).toMatch(/^\/custom-styles\/@tanstack-start\/styles\.css/)
expect(html).toContain(DEV_STYLES_ATTR)
})

test.describe('with JavaScript disabled', () => {
Expand Down
Loading
Loading