From 7bb03099b468f8e6b763a6f1f457fda806f18c53 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 22 Mar 2026 11:39:30 +0100 Subject: [PATCH 1/3] feat: transformAssets replaces transformAssetUrls --- docs/router/guide/document-head-management.md | 18 + .../framework/react/guide/cdn-asset-urls.md | 168 ++++++--- .../basic-rsc/src/routeTree.gen.ts | 5 +- .../clerk-basic/src/routeTree.gen.ts | 2 +- .../transform-asset-urls/package.json | 40 ++- .../transform-asset-urls/playwright.config.ts | 21 +- .../src/routes/__root.tsx | 7 +- .../transform-asset-urls/src/routes/index.tsx | 2 +- .../transform-asset-urls/src/server.ts | 137 +++++-- .../transform-asset-urls/tests/cdn-server.mjs | 17 +- .../tests/transform-asset-urls.spec.ts | 38 +- packages/react-router/src/HeadContent.dev.tsx | 5 +- packages/react-router/src/HeadContent.tsx | 9 +- packages/react-router/src/Match.tsx | 4 +- .../react-router/src/headContentUtils.tsx | 34 +- packages/react-router/tests/Scripts.test.tsx | 134 +++++++ packages/router-core/src/index.ts | 9 +- packages/router-core/src/manifest.ts | 38 +- packages/solid-router/src/HeadContent.dev.tsx | 5 +- packages/solid-router/src/HeadContent.tsx | 9 +- .../solid-router/src/headContentUtils.tsx | 28 +- packages/solid-router/tests/Scripts.test.tsx | 58 +++ .../src/createStartHandler.ts | 132 ++++++- packages/start-server-core/src/index.tsx | 8 + .../start-server-core/src/router-manifest.ts | 10 +- .../src/transformAssetUrls.ts | 335 ++++++++++++++---- .../tests/transformAssets.test.ts | 231 ++++++++++++ packages/vue-router/src/HeadContent.dev.tsx | 11 +- packages/vue-router/src/HeadContent.tsx | 15 +- packages/vue-router/src/headContentUtils.tsx | 26 +- packages/vue-router/tests/Scripts.test.tsx | 58 +++ 31 files changed, 1374 insertions(+), 240 deletions(-) create mode 100644 packages/start-server-core/tests/transformAssets.test.ts diff --git a/docs/router/guide/document-head-management.md b/docs/router/guide/document-head-management.md index cf66544a4af..381462e20a5 100644 --- a/docs/router/guide/document-head-management.md +++ b/docs/router/guide/document-head-management.md @@ -68,6 +68,24 @@ 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: + +```tsx + + + +``` + +`assetCrossOrigin` only applies to manifest-managed asset links emitted by Start. +If you also set `crossOrigin` via `transformAssets` (either the object shorthand +or a callback return value), `assetCrossOrigin` wins. + ### Start/Full-Stack Applications diff --git a/docs/start/framework/react/guide/cdn-asset-urls.md b/docs/start/framework/react/guide/cdn-asset-urls.md index 977c030c685..745250b8216 100644 --- a/docs/start/framework/react/guide/cdn-asset-urls.md +++ b/docs/start/framework/react/guide/cdn-asset-urls.md @@ -5,13 +5,15 @@ title: CDN Asset URLs # CDN Asset URLs -> **Experimental:** `transformAssetUrls` is experimental and subject to change. +> **Experimental:** `transformAssets` is experimental and subject to change. -When deploying to production, you may want to serve your static assets (JavaScript, CSS) from a CDN. The `transformAssetUrls` option on `createStartHandler` lets you rewrite asset URLs at runtime — for example, prepending a CDN origin that is only known when the server starts. +When deploying to production, you may want to serve your static assets (JavaScript, CSS) from a CDN. The `transformAssets` option on `createStartHandler` lets you rewrite asset URLs at runtime - for example, prepending a CDN origin that is only known when the server starts. + +`transformAssetUrls` still works, but it is deprecated and now delegates to `transformAssets` with a development warning. ## Why Runtime URL Rewriting? -Vite's `base` config is evaluated at build time. If your CDN URL is determined at deploy time (via environment variables, dynamic configuration, etc.), you need a way to rewrite URLs at runtime. `transformAssetUrls` solves this for the URLs that TanStack Start manages in its manifest: +Vite's `base` config is evaluated at build time. If your CDN URL is determined at deploy time (via environment variables, dynamic configuration, etc.), you need a way to rewrite URLs at runtime. `transformAssets` solves this for the URLs that TanStack Start manages in its manifest: - `` tags (JS preloads) - `` tags (CSS) @@ -33,7 +35,7 @@ import { createServerEntry } from '@tanstack/react-start/server-entry' const handler = createStartHandler({ handler: defaultStreamHandler, - transformAssetUrls: process.env.CDN_ORIGIN || '', + transformAssets: process.env.CDN_ORIGIN || '', }) export default createServerEntry({ fetch: handler }) @@ -43,9 +45,46 @@ If `CDN_ORIGIN` is `https://cdn.example.com` and an asset URL is `/assets/index- When the string is empty (or not set), the URLs are left unchanged. +### Object Shorthand (Prefix + CrossOrigin) + +If you also need to set `crossOrigin` on manifest-managed `` tags, use the object shorthand with `prefix` and `crossOrigin`: + +```tsx +// src/server.ts +import { + createStartHandler, + defaultStreamHandler, +} from '@tanstack/react-start/server' +import { createServerEntry } from '@tanstack/react-start/server-entry' + +const handler = createStartHandler({ + handler: defaultStreamHandler, + transformAssets: { + prefix: process.env.CDN_ORIGIN || '', + crossOrigin: 'anonymous', + }, +}) + +export default createServerEntry({ fetch: handler }) +``` + +`crossOrigin` accepts either a single value applied to all asset kinds, or a per-kind record (matching the `HeadContent assetCrossOrigin` shape): + +```tsx +transformAssets: { + prefix: 'https://cdn.example.com', + crossOrigin: { + modulepreload: 'anonymous', + stylesheet: 'use-credentials', + }, +} +``` + +Kinds not listed in the per-kind record receive no `crossOrigin` attribute. Like the string shorthand, the object shorthand is always cached (`cache: true`). + ### Callback -For more control, pass a callback that receives `{ url, type }` and returns a new URL (or a `Promise` of one). By default, the transformed manifest is cached after the first request (`cache: true`), so the callback only runs once in production: +For more control, pass a callback that receives `{ kind, url }` and returns a string, or `{ href, crossOrigin? }` (or a `Promise` of either). By default, the transformed manifest is cached after the first request (`cache: true`), so the callback only runs once in production: ```tsx // src/server.ts @@ -57,10 +96,17 @@ import { createServerEntry } from '@tanstack/react-start/server-entry' const handler = createStartHandler({ handler: defaultStreamHandler, - transformAssetUrls: ({ url, type }) => { - // Only rewrite JS and CSS, leave client entry unchanged - if (type === 'clientEntry') return url - return `https://cdn.example.com${url}` + transformAssets: ({ kind, url }) => { + const href = `https://cdn.example.com${url}` + + if (kind === 'modulepreload') { + return { + href, + crossOrigin: 'anonymous', + } + } + + return { href } }, }) @@ -69,17 +115,19 @@ export default createServerEntry({ fetch: handler }) If you need per-request behavior (for example, choosing a CDN based on a header), use the object form with `cache: false`. -The `type` parameter tells you what kind of asset URL is being transformed: +The `kind` parameter tells you what kind of asset URL is being transformed: -| `type` | Description | +| `kind` | Description | | ----------------- | ---------------------------------------------------- | | `'modulepreload'` | JS module preload URL (``) | | `'stylesheet'` | CSS stylesheet URL (``) | | `'clientEntry'` | Client entry module URL (used in `import('...')`) | +`crossOrigin` applies to manifest-managed link tags. For the client entry, returning `{ href }` is equivalent to returning a string. + ### Object Form (Explicit Cache Control) -For per-request transforms — where the CDN URL depends on request-specific data like headers — use the object form with `cache: false`: +For per-request transforms - where the CDN URL depends on request-specific data like headers - use the object form with `cache: false`: ```tsx // src/server.ts @@ -92,14 +140,22 @@ import { getRequest } from '@tanstack/react-start/server' const handler = createStartHandler({ handler: defaultStreamHandler, - transformAssetUrls: { - transform: ({ url, type }) => { + transformAssets: { + transform: ({ kind, url }) => { const region = getRequest().headers.get('x-region') || 'us' const cdnBase = region === 'eu' ? 'https://cdn-eu.example.com' : 'https://cdn-us.example.com' - return `${cdnBase}${url}` + + if (kind === 'modulepreload') { + return { + href: `${cdnBase}${url}`, + crossOrigin: 'anonymous', + } + } + + return { href: `${cdnBase}${url}` } }, cache: false, }, @@ -110,34 +166,43 @@ export default createServerEntry({ fetch: handler }) The object form accepts: -| Property | Type | Description | -| ----------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `transform` | `string \| (asset) => string \| Promise` | A string prefix or callback, same as the shorthand forms above. | -| `createTransform` | `(ctx: { warmup: true } \| { warmup: false; request: Request }) => (asset) => string \| Promise` | Async factory that runs once per manifest computation and returns a per-asset transform. Mutually exclusive with `transform`. | -| `cache` | `boolean` | Whether to cache the transformed manifest. Defaults to `true`. | -| `warmup` | `boolean` | When `true`, warms up the cached manifest on server startup (prod only). Defaults to `false`. | +| Property | Type | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `transform` | `string \| (asset) => string \| { href, crossOrigin? } \| Promise<...>` | A string prefix or callback, same as the shorthand forms above. | +| `createTransform` | `(ctx: { warmup: true } \| { warmup: false; request: Request }) => (asset) => string \| { href, crossOrigin? } \| Promise<...>` | Async factory that runs once per manifest computation and returns a per-asset transform. Mutually exclusive with `transform`. | +| `cache` | `boolean` | Whether to cache the transformed manifest. Defaults to `true`. | +| `warmup` | `boolean` | When `true`, warms up the cached manifest on server startup (prod only). Defaults to `false`. | If you need to do async work once per manifest computation (e.g. fetch a CDN origin from a service) and then transform many URLs, prefer `createTransform`: ```ts -transformAssetUrls: { +transformAssets: { cache: false, async createTransform(ctx) { if (ctx.warmup) { - // optional: return a default transform during warmup - return ({ url }) => url + return ({ url }) => ({ href: url }) } const region = ctx.request.headers.get('x-region') || 'us' const cdnBase = await fetchCdnBaseForRegion(region) - return ({ url }) => `${cdnBase}${url}` + + return ({ kind, url }) => { + if (kind === 'modulepreload') { + return { + href: `${cdnBase}${url}`, + crossOrigin: 'anonymous', + } + } + + return { href: `${cdnBase}${url}` } + } }, } ``` ## Caching Behavior -By default, **all forms** of `transformAssetUrls` cache the transformed manifest after the first request (`cache: true`). This means the transform function runs once on the first request, and the result is reused for every subsequent request in production. +By default, all forms of `transformAssets` cache the transformed manifest after the first request (`cache: true`). This means the transform function runs once on the first request, and the result is reused for every subsequent request in production. | Form | Default cache | Behavior | | -------------------------------------- | ------------- | ---------------------------------------------------------- | @@ -154,7 +219,7 @@ If you're using the object form with `cache: true`, you can set `warmup: true` to compute the transformed manifest in the background at server startup. ```ts -transformAssetUrls: { +transformAssets: { transform: process.env.CDN_ORIGIN || '', cache: true, warmup: true, @@ -165,11 +230,34 @@ This has no effect in development mode, or when `cache: false`. > **Note:** In development mode (`TSS_DEV_SERVER`), caching is always skipped regardless of the `cache` setting, so you always get fresh manifests. +## With `HeadContent assetCrossOrigin` + +If you want to set cross-origin behavior from the app shell instead of the server entry, `HeadContent` also accepts `assetCrossOrigin`: + +```tsx + +``` + +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 you return from route `head()` functions. + ## Recommended: Set `base: ''` for Client-Side Navigation -`transformAssetUrls` rewrites the URLs in the SSR HTML — modulepreload hints, stylesheets, and the client entry script. This means the browser's initial page load fetches all assets from the CDN. +`transformAssets` rewrites the URLs in the SSR HTML - modulepreload hints, stylesheets, and the client entry script. This means the browser's initial page load fetches all assets from the CDN. -However, when users navigate client-side (e.g., clicking a ``), TanStack Router lazy-loads route chunks using `import()` calls with paths that were baked in at **build time** by Vite. By default, Vite uses `base: '/'`, which produces absolute paths like `/assets/about-abc123.js`. These resolve against the **app server's origin**, not the CDN — even though the entry module was loaded from the CDN. +However, when users navigate client-side (e.g., clicking a ``), TanStack Router lazy-loads route chunks using `import()` calls with paths that were baked in at build time by Vite. By default, Vite uses `base: '/'`, which produces absolute paths like `/assets/about-abc123.js`. These resolve against the app server's origin, not the CDN - even though the entry module was loaded from the CDN. To fix this, set `base: ''` in your Vite config: @@ -181,29 +269,29 @@ export default defineConfig({ }) ``` -With `base: ''`, Vite generates **relative** import paths for client-side chunks. Since the client entry module was loaded from the CDN (thanks to `transformAssetUrls`), all relative `import()` calls resolve against the CDN origin. This ensures that lazy-loaded route chunks during client-side navigation are also served from the CDN. +With `base: ''`, Vite generates relative import paths for client-side chunks. Since the client entry module was loaded from the CDN (thanks to `transformAssets`), all relative `import()` calls resolve against the CDN origin. This ensures that lazy-loaded route chunks during client-side navigation are also served from the CDN. -Using an empty string rather than `'./'` is important — both produce relative client-side imports, but `base: ''` preserves the correct root-relative paths (`/assets/...`) in the SSR manifest so that `transformAssetUrls` can properly prepend the CDN origin. +Using an empty string rather than `'./'` is important - both produce relative client-side imports, but `base: ''` preserves the correct root-relative paths (`/assets/...`) in the SSR manifest so that `transformAssets` can properly prepend the CDN origin. -| `base` setting | SSR assets (initial load) | Client-side navigation chunks | -| --------------- | ------------------------------ | ------------------------------ | -| `'/'` (default) | CDN (via `transformAssetUrls`) | App server | -| `''` | CDN (via `transformAssetUrls`) | CDN (relative to entry module) | +| `base` setting | SSR assets (initial load) | Client-side navigation chunks | +| --------------- | --------------------------- | ------------------------------ | +| `'/'` (default) | CDN (via `transformAssets`) | App server | +| `''` | CDN (via `transformAssets`) | CDN (relative to entry module) | -> **Tip:** `base: ''` is recommended whenever you use `transformAssetUrls` so that all assets — both on initial load and during client-side navigation — are consistently served from the CDN. +> **Tip:** `base: ''` is recommended whenever you use `transformAssets` so that all assets - both on initial load and during client-side navigation - are consistently served from the CDN. -## What This Does NOT Cover +## What This Does Not Cover -`transformAssetUrls` only rewrites URLs in the TanStack Start manifest — the tags emitted during SSR for preloading and bootstrapping the application. +`transformAssets` only rewrites URLs in the TanStack Start manifest - the tags emitted during SSR for preloading and bootstrapping the application. -It does **not** rewrite asset URLs that are imported directly in your components: +It does not rewrite asset URLs imported directly in your components: ```tsx // This import resolves to a URL at build time by Vite import logo from './logo.svg' function Header() { - return // This URL is NOT affected by transformAssetUrls + return // This URL is NOT affected by transformAssets } ``` diff --git a/e2e/react-start/basic-rsc/src/routeTree.gen.ts b/e2e/react-start/basic-rsc/src/routeTree.gen.ts index 500f71a9f73..9bc5483f542 100644 --- a/e2e/react-start/basic-rsc/src/routeTree.gen.ts +++ b/e2e/react-start/basic-rsc/src/routeTree.gen.ts @@ -142,7 +142,7 @@ declare module '@tanstack/react-router' { '/_layout': { id: '/_layout' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } @@ -170,7 +170,7 @@ declare module '@tanstack/react-router' { '/_layout/_layout-2': { id: '/_layout/_layout-2' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutLayout2RouteImport parentRoute: typeof LayoutRoute } @@ -249,6 +249,7 @@ import type { getRouter } from './router.tsx' import type { createStart } from '@tanstack/react-start' declare module '@tanstack/react-start' { interface Register { + ssr: true router: Awaited> } } diff --git a/e2e/react-start/clerk-basic/src/routeTree.gen.ts b/e2e/react-start/clerk-basic/src/routeTree.gen.ts index a6767e0efd5..5b9f75e27a9 100644 --- a/e2e/react-start/clerk-basic/src/routeTree.gen.ts +++ b/e2e/react-start/clerk-basic/src/routeTree.gen.ts @@ -93,7 +93,7 @@ declare module '@tanstack/react-router' { '/_authed': { id: '/_authed' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof AuthedRouteImport parentRoute: typeof rootRouteImport } diff --git a/e2e/react-start/transform-asset-urls/package.json b/e2e/react-start/transform-asset-urls/package.json index 7c9efd5caa5..ea8c501b591 100644 --- a/e2e/react-start/transform-asset-urls/package.json +++ b/e2e/react-start/transform-asset-urls/package.json @@ -11,26 +11,28 @@ "manual:cdn": "CDN_PORT=3002 node tests/cdn-server.mjs", "manual:app": "pnpm build && PORT=3000 CDN_ORIGIN=http://localhost:3002 pnpm start", "manual:both": "sh -c \"CDN_PORT=3002 node tests/cdn-server.mjs & pnpm build && PORT=3000 CDN_ORIGIN=http://localhost:3002 pnpm start\"", - "manual:both:string": "TRANSFORM_ASSET_URLS_MODE=string pnpm manual:both", - "manual:both:function": "TRANSFORM_ASSET_URLS_MODE=function pnpm manual:both", - "manual:both:options": "TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=createTransform pnpm manual:both", - "manual:both:options:transform": "TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=transform pnpm manual:both", - "manual:both:options:createTransform": "TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=createTransform pnpm manual:both", - "test:e2e:string": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=string playwright test --project=chromium", - "test:e2e:function": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=function playwright test --project=chromium", - "test:e2e:options": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=transform playwright test --project=chromium", - "test:e2e:options:transform": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=transform playwright test --project=chromium", - "test:e2e:options:createTransform": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=createTransform playwright test --project=chromium", - "test:e2e:options:transform:cache-true:warmup-true": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=transform TRANSFORM_ASSET_URLS_OPTIONS_CACHE=true TRANSFORM_ASSET_URLS_OPTIONS_WARMUP=true playwright test --project=chromium", - "test:e2e:options:transform:cache-true:warmup-false": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=transform TRANSFORM_ASSET_URLS_OPTIONS_CACHE=true TRANSFORM_ASSET_URLS_OPTIONS_WARMUP=false playwright test --project=chromium", - "test:e2e:options:transform:cache-false:warmup-true": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=transform TRANSFORM_ASSET_URLS_OPTIONS_CACHE=false TRANSFORM_ASSET_URLS_OPTIONS_WARMUP=true playwright test --project=chromium", - "test:e2e:options:transform:cache-false:warmup-false": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=transform TRANSFORM_ASSET_URLS_OPTIONS_CACHE=false TRANSFORM_ASSET_URLS_OPTIONS_WARMUP=false playwright test --project=chromium", - "test:e2e:options:createTransform:cache-true:warmup-true": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=createTransform TRANSFORM_ASSET_URLS_OPTIONS_CACHE=true TRANSFORM_ASSET_URLS_OPTIONS_WARMUP=true playwright test --project=chromium", - "test:e2e:options:createTransform:cache-true:warmup-false": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=createTransform TRANSFORM_ASSET_URLS_OPTIONS_CACHE=true TRANSFORM_ASSET_URLS_OPTIONS_WARMUP=false playwright test --project=chromium", - "test:e2e:options:createTransform:cache-false:warmup-true": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=createTransform TRANSFORM_ASSET_URLS_OPTIONS_CACHE=false TRANSFORM_ASSET_URLS_OPTIONS_WARMUP=true playwright test --project=chromium", - "test:e2e:options:createTransform:cache-false:warmup-false": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSET_URLS_MODE=options TRANSFORM_ASSET_URLS_OPTIONS_KIND=createTransform TRANSFORM_ASSET_URLS_OPTIONS_CACHE=false TRANSFORM_ASSET_URLS_OPTIONS_WARMUP=false playwright test --project=chromium", + "manual:both:string": "TRANSFORM_ASSETS_MODE=string pnpm manual:both", + "manual:both:function": "TRANSFORM_ASSETS_MODE=function pnpm manual:both", + "manual:both:options": "TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=createTransform pnpm manual:both", + "manual:both:options:transform": "TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=transform pnpm manual:both", + "manual:both:options:createTransform": "TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=createTransform pnpm manual:both", + "manual:both:deprecated": "USE_DEPRECATED_TRANSFORM_ASSET_URLS=true TRANSFORM_ASSETS_MODE=function pnpm manual:both", + "test:e2e:string": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=string playwright test --project=chromium", + "test:e2e:function": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=function playwright test --project=chromium", + "test:e2e:deprecated": "rm -rf dist; rm -rf port*.txt; USE_DEPRECATED_TRANSFORM_ASSET_URLS=true TRANSFORM_ASSETS_MODE=function playwright test --project=chromium", + "test:e2e:options": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=transform playwright test --project=chromium", + "test:e2e:options:transform": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=transform playwright test --project=chromium", + "test:e2e:options:createTransform": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=createTransform playwright test --project=chromium", + "test:e2e:options:transform:cache-true:warmup-true": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=transform TRANSFORM_ASSETS_OPTIONS_CACHE=true TRANSFORM_ASSETS_OPTIONS_WARMUP=true playwright test --project=chromium", + "test:e2e:options:transform:cache-true:warmup-false": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=transform TRANSFORM_ASSETS_OPTIONS_CACHE=true TRANSFORM_ASSETS_OPTIONS_WARMUP=false playwright test --project=chromium", + "test:e2e:options:transform:cache-false:warmup-true": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=transform TRANSFORM_ASSETS_OPTIONS_CACHE=false TRANSFORM_ASSETS_OPTIONS_WARMUP=true playwright test --project=chromium", + "test:e2e:options:transform:cache-false:warmup-false": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=transform TRANSFORM_ASSETS_OPTIONS_CACHE=false TRANSFORM_ASSETS_OPTIONS_WARMUP=false playwright test --project=chromium", + "test:e2e:options:createTransform:cache-true:warmup-true": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=createTransform TRANSFORM_ASSETS_OPTIONS_CACHE=true TRANSFORM_ASSETS_OPTIONS_WARMUP=true playwright test --project=chromium", + "test:e2e:options:createTransform:cache-true:warmup-false": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=createTransform TRANSFORM_ASSETS_OPTIONS_CACHE=true TRANSFORM_ASSETS_OPTIONS_WARMUP=false playwright test --project=chromium", + "test:e2e:options:createTransform:cache-false:warmup-true": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=createTransform TRANSFORM_ASSETS_OPTIONS_CACHE=false TRANSFORM_ASSETS_OPTIONS_WARMUP=true playwright test --project=chromium", + "test:e2e:options:createTransform:cache-false:warmup-false": "rm -rf dist; rm -rf port*.txt; TRANSFORM_ASSETS_MODE=options TRANSFORM_ASSETS_OPTIONS_KIND=createTransform TRANSFORM_ASSETS_OPTIONS_CACHE=false TRANSFORM_ASSETS_OPTIONS_WARMUP=false playwright test --project=chromium", "test:e2e:options:matrix": "pnpm test:e2e:options:transform:cache-true:warmup-true && pnpm test:e2e:options:transform:cache-true:warmup-false && pnpm test:e2e:options:transform:cache-false:warmup-true && pnpm test:e2e:options:transform:cache-false:warmup-false && pnpm test:e2e:options:createTransform:cache-true:warmup-true && pnpm test:e2e:options:createTransform:cache-true:warmup-false && pnpm test:e2e:options:createTransform:cache-false:warmup-true && pnpm test:e2e:options:createTransform:cache-false:warmup-false", - "test:e2e": "pnpm test:e2e:string && pnpm test:e2e:function && pnpm test:e2e:options:matrix" + "test:e2e": "pnpm test:e2e:string && pnpm test:e2e:function && pnpm test:e2e:deprecated && pnpm test:e2e:options:matrix" }, "dependencies": { "@tanstack/react-router": "workspace:^", diff --git a/e2e/react-start/transform-asset-urls/playwright.config.ts b/e2e/react-start/transform-asset-urls/playwright.config.ts index 53cc1b38df6..fe009fb9aff 100644 --- a/e2e/react-start/transform-asset-urls/playwright.config.ts +++ b/e2e/react-start/transform-asset-urls/playwright.config.ts @@ -7,11 +7,13 @@ const CDN_PORT = await getTestServerPort(`${packageJson.name}_cdn`) const baseURL = `http://localhost:${APP_PORT}` const cdnOrigin = `http://localhost:${CDN_PORT}` -const transformMode = process.env.TRANSFORM_ASSET_URLS_MODE || 'string' +const transformMode = process.env.TRANSFORM_ASSETS_MODE || 'string' const optionsKind = - process.env.TRANSFORM_ASSET_URLS_OPTIONS_KIND || 'createTransform' -const optionsCache = process.env.TRANSFORM_ASSET_URLS_OPTIONS_CACHE || 'true' -const optionsWarmup = process.env.TRANSFORM_ASSET_URLS_OPTIONS_WARMUP || 'true' + process.env.TRANSFORM_ASSETS_OPTIONS_KIND || 'createTransform' +const optionsCache = process.env.TRANSFORM_ASSETS_OPTIONS_CACHE || 'true' +const optionsWarmup = process.env.TRANSFORM_ASSETS_OPTIONS_WARMUP || 'true' +const useDeprecatedTransformAssetUrls = + process.env.USE_DEPRECATED_TRANSFORM_ASSET_URLS || 'false' export default defineConfig({ testDir: './tests', @@ -35,7 +37,7 @@ export default defineConfig({ }, { // App server — builds the project then starts the srvx server - // with CDN_ORIGIN so that transformAssetUrls rewrites manifest URLs + // with CDN_ORIGIN so that transformAssets rewrites manifest URLs command: `pnpm build && pnpm start`, url: baseURL, reuseExistingServer: !process.env.CI, @@ -43,10 +45,11 @@ export default defineConfig({ env: { PORT: String(APP_PORT), CDN_ORIGIN: cdnOrigin, - TRANSFORM_ASSET_URLS_MODE: transformMode, - TRANSFORM_ASSET_URLS_OPTIONS_KIND: optionsKind, - TRANSFORM_ASSET_URLS_OPTIONS_CACHE: optionsCache, - TRANSFORM_ASSET_URLS_OPTIONS_WARMUP: optionsWarmup, + TRANSFORM_ASSETS_MODE: transformMode, + TRANSFORM_ASSETS_OPTIONS_KIND: optionsKind, + TRANSFORM_ASSETS_OPTIONS_CACHE: optionsCache, + TRANSFORM_ASSETS_OPTIONS_WARMUP: optionsWarmup, + USE_DEPRECATED_TRANSFORM_ASSET_URLS: useDeprecatedTransformAssetUrls, }, timeout: 120_000, }, diff --git a/e2e/react-start/transform-asset-urls/src/routes/__root.tsx b/e2e/react-start/transform-asset-urls/src/routes/__root.tsx index 4d1f1321a1d..ff4961a1a4a 100644 --- a/e2e/react-start/transform-asset-urls/src/routes/__root.tsx +++ b/e2e/react-start/transform-asset-urls/src/routes/__root.tsx @@ -27,7 +27,12 @@ function RootComponent() { return ( - +
diff --git a/e2e/react-start/transform-asset-urls/src/routes/index.tsx b/e2e/react-start/transform-asset-urls/src/routes/index.tsx index e7aa38b8d90..1292f9757f9 100644 --- a/e2e/react-start/transform-asset-urls/src/routes/index.tsx +++ b/e2e/react-start/transform-asset-urls/src/routes/index.tsx @@ -9,7 +9,7 @@ function Home() {

Welcome Home

- This page tests the transformAssetUrls feature. + This page tests the transformAssets feature.

About diff --git a/e2e/react-start/transform-asset-urls/src/server.ts b/e2e/react-start/transform-asset-urls/src/server.ts index 1757f035962..e53d8f1f583 100644 --- a/e2e/react-start/transform-asset-urls/src/server.ts +++ b/e2e/react-start/transform-asset-urls/src/server.ts @@ -3,52 +3,127 @@ import { defaultStreamHandler, } from '@tanstack/react-start/server' import { createServerEntry } from '@tanstack/react-start/server-entry' +import type { TransformAssetUrls } from '@tanstack/react-start/server' + +type TransformAssetsFn = (ctx: { + kind: 'modulepreload' | 'stylesheet' | 'clientEntry' + url: string +}) => + | string + | { + href: string + crossOrigin?: 'anonymous' | 'use-credentials' + } const cdnOrigin = process.env.CDN_ORIGIN -const transformMode = process.env.TRANSFORM_ASSET_URLS_MODE || 'string' +const transformMode = process.env.TRANSFORM_ASSETS_MODE || 'string' const optionsKind = - process.env.TRANSFORM_ASSET_URLS_OPTIONS_KIND || 'createTransform' -const optionsCache = process.env.TRANSFORM_ASSET_URLS_OPTIONS_CACHE || 'true' -const optionsWarmup = process.env.TRANSFORM_ASSET_URLS_OPTIONS_WARMUP || 'true' + process.env.TRANSFORM_ASSETS_OPTIONS_KIND || 'createTransform' +const optionsCache = process.env.TRANSFORM_ASSETS_OPTIONS_CACHE || 'true' +const optionsWarmup = process.env.TRANSFORM_ASSETS_OPTIONS_WARMUP || 'true' +const useDeprecatedTransformAssetUrls = + process.env.USE_DEPRECATED_TRANSFORM_ASSET_URLS === 'true' const cache = optionsCache !== 'false' const warmup = optionsWarmup === 'true' console.log( - `[server-entry]: using custom server entry with transformAssetUrls (${transformMode}${transformMode === 'options' ? `:${optionsKind}` : ''})${cdnOrigin ? ` (CDN: ${cdnOrigin})` : ' (no CDN)'}`, + `[server-entry]: using custom server entry with ${useDeprecatedTransformAssetUrls ? 'transformAssetUrls' : 'transformAssets'} (${transformMode}${transformMode === 'options' ? `:${optionsKind}` : ''})${cdnOrigin ? ` (CDN: ${cdnOrigin})` : ' (no CDN)'}`, ) +const createTransformAssetsFn = + (cdn: string): TransformAssetsFn => + ({ kind, url }) => { + const href = `${cdn}${url}` + + if (kind === 'modulepreload') { + return { + href, + crossOrigin: 'anonymous', + } + } + + if (kind === 'stylesheet') { + return { + href, + crossOrigin: 'anonymous', + } + } + + return { href } + } + +const createTransformAssetsConfig = (cdn: string) => { + const transformAssetsFn = createTransformAssetsFn(cdn) + + if (transformMode === 'function') { + return transformAssetsFn + } + + if (transformMode === 'options') { + if (optionsKind === 'transform') { + return { + transform: transformAssetsFn, + cache, + warmup, + } + } + + return { + createTransform: async () => { + return transformAssetsFn + }, + cache, + warmup, + } + } + + return cdn +} + +const createDeprecatedTransformAssetUrlsConfig = ( + cdn: string, +): TransformAssetUrls => { + if (transformMode === 'function') { + return ({ url }) => `${cdn}${url}` + } + + if (transformMode === 'options') { + if (optionsKind === 'transform') { + return { + transform: ({ url }) => `${cdn}${url}`, + cache, + warmup, + } + } + + return { + createTransform: async () => { + return ({ url }) => `${cdn}${url}` + }, + cache, + warmup, + } + } + + return cdn +} + const handler = createStartHandler( cdnOrigin ? { handler: defaultStreamHandler, - transformAssetUrls: (() => { - const cdn = cdnOrigin.replace(/\/+$/, '') - - if (transformMode === 'function') { - return ({ url }: { url: string }) => `${cdn}${url}` - } - - if (transformMode === 'options') { - if (optionsKind === 'transform') { - return { - transform: ({ url }: { url: string }) => `${cdn}${url}`, - cache, - warmup, - } + ...(useDeprecatedTransformAssetUrls + ? { + transformAssetUrls: createDeprecatedTransformAssetUrlsConfig( + cdnOrigin.replace(/\/+$/, ''), + ), } - - return { - createTransform: async () => { - return ({ url }: { url: string }) => `${cdn}${url}` - }, - cache, - warmup, - } - } - - return cdn - })(), + : { + transformAssets: createTransformAssetsConfig( + cdnOrigin.replace(/\/+$/, ''), + ), + }), } : defaultStreamHandler, ) diff --git a/e2e/react-start/transform-asset-urls/tests/cdn-server.mjs b/e2e/react-start/transform-asset-urls/tests/cdn-server.mjs index 8b06bf6d606..a97f142e876 100644 --- a/e2e/react-start/transform-asset-urls/tests/cdn-server.mjs +++ b/e2e/react-start/transform-asset-urls/tests/cdn-server.mjs @@ -13,9 +13,22 @@ app.get('/health', (_req, res) => { res.status(200).send('ok') }) -// Serve the built client assets with CORS headers to simulate a CDN +// Serve the built client assets with CORS headers to simulate a CDN. +// Origin reflection is intentional for this test server: the e2e tests use +// crossorigin="use-credentials" which requires Access-Control-Allow-Origin +// to echo the requesting origin (wildcard '*' is not allowed with credentials). +// Do NOT copy this pattern for production — validate origins against an allowlist. app.use((req, res, next) => { - res.setHeader('Access-Control-Allow-Origin', '*') + const origin = req.headers.origin + + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Access-Control-Allow-Credentials', 'true') + res.setHeader('Vary', 'Origin') + } else { + res.setHeader('Access-Control-Allow-Origin', '*') + } + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') res.setHeader('Access-Control-Allow-Headers', '*') next() diff --git a/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts b/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts index 5ab10da6f2f..b0064122433 100644 --- a/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts +++ b/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts @@ -19,23 +19,23 @@ async function getSSRHtml(page: Page, path = '/') { return response.text() } -test.describe('transformAssetUrls with CDN prefix', () => { +test.describe('transformAssets with CDN prefix', () => { test('test run mode is set (string|function|options)', async () => { - expect(process.env.TRANSFORM_ASSET_URLS_MODE).toMatch( + expect(process.env.TRANSFORM_ASSETS_MODE).toMatch( /^(string|function|options)$/, ) - if (process.env.TRANSFORM_ASSET_URLS_MODE === 'options') { - expect(process.env.TRANSFORM_ASSET_URLS_OPTIONS_KIND).toMatch( + if (process.env.TRANSFORM_ASSETS_MODE === 'options') { + const optionsCache = process.env.TRANSFORM_ASSETS_OPTIONS_CACHE || 'true' + const optionsWarmup = + process.env.TRANSFORM_ASSETS_OPTIONS_WARMUP || 'true' + + expect(process.env.TRANSFORM_ASSETS_OPTIONS_KIND).toMatch( /^(transform|createTransform)$/, ) - expect(process.env.TRANSFORM_ASSET_URLS_OPTIONS_CACHE).toMatch( - /^(true|false)$/, - ) - expect(process.env.TRANSFORM_ASSET_URLS_OPTIONS_WARMUP).toMatch( - /^(true|false)$/, - ) + expect(optionsCache).toMatch(/^(true|false)$/) + expect(optionsWarmup).toMatch(/^(true|false)$/) } }) @@ -56,6 +56,22 @@ test.describe('transformAssetUrls with CDN prefix', () => { } }) + test('SSR HTML contains expected crossorigin attributes', async ({ + page, + }) => { + const html = await getSSRHtml(page) + + const modulepreloadLink = html.match( + /]*rel="modulepreload"[^>]*crossorigin="anonymous"[^>]*>/, + ) + expect(modulepreloadLink).toBeTruthy() + + const stylesheetLink = html.match( + /]*rel="stylesheet"[^>]*crossorigin="use-credentials"[^>]*>/, + ) + expect(stylesheetLink).toBeTruthy() + }) + test('SSR HTML contains CDN-prefixed stylesheet link', async ({ page }) => { const html = await getSSRHtml(page) @@ -133,7 +149,7 @@ test.describe('transformAssetUrls with CDN prefix', () => { // Page content renders await expect(page.getByTestId('home-heading')).toHaveText('Welcome Home') await expect(page.getByTestId('home-content')).toContainText( - 'transformAssetUrls', + 'transformAssets', ) }) diff --git a/packages/react-router/src/HeadContent.dev.tsx b/packages/react-router/src/HeadContent.dev.tsx index 3dfaa6613e0..25cb4e410ce 100644 --- a/packages/react-router/src/HeadContent.dev.tsx +++ b/packages/react-router/src/HeadContent.dev.tsx @@ -3,6 +3,7 @@ import { Asset } from './Asset' import { useRouter } from './useRouter' import { useHydrated } from './ClientOnly' import { useTags } from './headContentUtils' +import type { HeadContentProps } from './HeadContent' const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles' @@ -15,8 +16,8 @@ const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles' * * @link https://tanstack.com/router/latest/docs/framework/react/guide/document-head-management */ -export function HeadContent() { - const tags = useTags() +export function HeadContent(props: HeadContentProps) { + const tags = useTags(props.assetCrossOrigin) const router = useRouter() const nonce = router.options.ssr?.nonce const hydrated = useHydrated() diff --git a/packages/react-router/src/HeadContent.tsx b/packages/react-router/src/HeadContent.tsx index e917fd95964..c5d87a1778f 100644 --- a/packages/react-router/src/HeadContent.tsx +++ b/packages/react-router/src/HeadContent.tsx @@ -2,14 +2,19 @@ import * as React from 'react' import { Asset } from './Asset' import { useRouter } from './useRouter' import { useTags } from './headContentUtils' +import type { AssetCrossOriginConfig } from '@tanstack/router-core' + +export interface HeadContentProps { + assetCrossOrigin?: AssetCrossOriginConfig +} /** * Render route-managed head tags (title, meta, links, styles, head scripts). * Place inside the document head of your app shell. * @link https://tanstack.com/router/latest/docs/framework/react/guide/document-head-management */ -export function HeadContent() { - const tags = useTags() +export function HeadContent(props: HeadContentProps) { + const tags = useTags(props.assetCrossOrigin) const router = useRouter() const nonce = router.options.ssr?.nonce return ( diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index ba21ee3896a..35f5b1c2f17 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -64,7 +64,7 @@ export const Match = React.memo(function MatchImpl({ // Subscribe directly to the match store from the pool. // The matchId prop is stable for this component's lifetime (set by Outlet), // and reconcileMatchPool reuses stores for the same matchId. - // eslint-disable-next-line react-hooks/rules-of-hooks + const matchStore = router.stores.activeMatchStoresById.get(matchId) if (!matchStore) { if (process.env.NODE_ENV !== 'production') { @@ -338,7 +338,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ return out } - // eslint-disable-next-line react-hooks/rules-of-hooks + const matchStore = router.stores.activeMatchStoresById.get(matchId) if (!matchStore) { if (process.env.NODE_ENV !== 'production') { diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx index 5340d8aa19e..b180af2941d 100644 --- a/packages/react-router/src/headContentUtils.tsx +++ b/packages/react-router/src/headContentUtils.tsx @@ -1,14 +1,23 @@ import * as React from 'react' import { useStore } from '@tanstack/react-store' -import { deepEqual, escapeHtml } from '@tanstack/router-core' +import { + deepEqual, + escapeHtml, + getAssetCrossOrigin, + resolveManifestAssetLink, +} from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' -import type { RouterManagedTag } from '@tanstack/router-core' +import type { + AssetCrossOriginConfig, + RouterManagedTag, +} from '@tanstack/router-core' function buildTagsFromMatches( router: ReturnType, nonce: string | undefined, matches: Array, + assetCrossOrigin?: AssetCrossOriginConfig, ): Array { const routeMeta = matches.map((match) => match.meta!).filter(Boolean) @@ -101,6 +110,9 @@ function buildTagsFromMatches( tag: 'link', attrs: { ...asset.attrs, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? + asset.attrs?.crossOrigin, suppressHydrationWarning: true, nonce, }, @@ -114,11 +126,15 @@ function buildTagsFromMatches( router.ssr?.manifest?.routes[route.id]?.preloads ?.filter(Boolean) .forEach((preload) => { + const preloadLink = resolveManifestAssetLink(preload) preloadLinks.push({ tag: 'link', attrs: { rel: 'modulepreload', - href: preload, + href: preloadLink.href, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'modulepreload') ?? + preloadLink.crossOrigin, nonce, }, }) @@ -170,7 +186,7 @@ function buildTagsFromMatches( * Build the list of head/link/meta/script tags to render for active matches. * Used internally by `HeadContent`. */ -export const useTags = () => { +export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const router = useRouter() const nonce = router.options.ssr?.nonce @@ -179,6 +195,7 @@ export const useTags = () => { router, nonce, router.stores.activeMatchesSnapshot.state, + assetCrossOrigin, ) } @@ -294,6 +311,9 @@ export const useTags = () => { tag: 'link', attrs: { ...asset.attrs, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? + asset.attrs?.crossOrigin, suppressHydrationWarning: true, nonce, }, @@ -317,11 +337,15 @@ export const useTags = () => { router.ssr?.manifest?.routes[route.id]?.preloads ?.filter(Boolean) .forEach((preload) => { + const preloadLink = resolveManifestAssetLink(preload) preloadLinks.push({ tag: 'link', attrs: { rel: 'modulepreload', - href: preload, + href: preloadLink.href, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'modulepreload') ?? + preloadLink.crossOrigin, nonce, }, }) diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index 7db908644bc..0ca10b794a5 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -28,6 +28,7 @@ const createTestManifest = (routeId: string) => ({ routes: { [routeId]: { + preloads: ['/main.js'], assets: [ { tag: 'link', @@ -442,6 +443,139 @@ describe('ssr HeadContent', () => { ).toHaveLength(1) }) + test('applies assetCrossOrigin to manifest assets and preloads', async () => { + const history = createTestBrowserHistory() + + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + {createPortal( + , + document.head, + )} + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } + + await router.load() + + await act(() => render()) + + await waitFor(() => { + expect(document.head.querySelector('link[rel="stylesheet"]')).toBeTruthy() + expect( + document.head.querySelector('link[rel="modulepreload"]'), + ).toBeTruthy() + }) + + expect( + document.head + .querySelector('link[rel="stylesheet"]') + ?.getAttribute('crossorigin'), + ).toBe('use-credentials') + expect( + document.head + .querySelector('link[rel="modulepreload"]') + ?.getAttribute('crossorigin'), + ).toBe('anonymous') + }) + + test('assetCrossOrigin overrides manifest crossOrigin values', async () => { + const history = createTestBrowserHistory() + + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + {createPortal( + , + document.head, + )} + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: { + routes: { + [rootRoute.id]: { + preloads: [ + { href: '/main.js', crossOrigin: 'use-credentials' as const }, + ], + assets: [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: '/main.css', + crossOrigin: 'use-credentials', + }, + }, + ], + }, + }, + } as any, + } + + await router.load() + + await act(() => render()) + + await waitFor(() => { + expect(document.head.querySelector('link[rel="stylesheet"]')).toBeTruthy() + expect( + document.head.querySelector('link[rel="modulepreload"]'), + ).toBeTruthy() + }) + + expect( + document.head + .querySelector('link[rel="stylesheet"]') + ?.getAttribute('crossorigin'), + ).toBe('anonymous') + expect( + document.head + .querySelector('link[rel="modulepreload"]') + ?.getAttribute('crossorigin'), + ).toBe('anonymous') + }) + test('keeps manifest stylesheet links mounted across repeated Link navigations', async () => { const history = createTestBrowserHistory() diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 649c4fac14e..2d4705c3d62 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -68,7 +68,14 @@ export type { } from './fileRoute' export type { ParsedLocation } from './location' -export type { Manifest, RouterManagedTag } from './manifest' +export type { + Manifest, + RouterManagedTag, + AssetCrossOrigin, + AssetCrossOriginConfig, + ManifestAssetLink, +} from './manifest' +export { getAssetCrossOrigin, resolveManifestAssetLink } from './manifest' export { isMatch } from './Matches' export type { AnyMatchAndValue, diff --git a/packages/router-core/src/manifest.ts b/packages/router-core/src/manifest.ts index e789e00af47..d7fa59f5dc1 100644 --- a/packages/router-core/src/manifest.ts +++ b/packages/router-core/src/manifest.ts @@ -1,9 +1,45 @@ +export type AssetCrossOrigin = 'anonymous' | 'use-credentials' + +export type AssetCrossOriginConfig = + | AssetCrossOrigin + | Partial> + +export type ManifestAssetLink = + | string + | { + href: string + crossOrigin?: AssetCrossOrigin + } + +export function getAssetCrossOrigin( + assetCrossOrigin: AssetCrossOriginConfig | undefined, + kind: 'modulepreload' | 'stylesheet', +): AssetCrossOrigin | undefined { + if (!assetCrossOrigin) { + return undefined + } + + if (typeof assetCrossOrigin === 'string') { + return assetCrossOrigin + } + + return assetCrossOrigin[kind] +} + +export function resolveManifestAssetLink(link: ManifestAssetLink) { + if (typeof link === 'string') { + return { href: link, crossOrigin: undefined } + } + + return link +} + export type Manifest = { routes: Record< string, { filePath?: string - preloads?: Array + preloads?: Array assets?: Array } > diff --git a/packages/solid-router/src/HeadContent.dev.tsx b/packages/solid-router/src/HeadContent.dev.tsx index c29ad96eabe..83c241d3fba 100644 --- a/packages/solid-router/src/HeadContent.dev.tsx +++ b/packages/solid-router/src/HeadContent.dev.tsx @@ -3,6 +3,7 @@ import { For, createEffect, createMemo } from 'solid-js' 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' @@ -15,8 +16,8 @@ const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles' * Development version: filters out dev styles link after hydration and * includes a fallback cleanup effect for hydration mismatch cases. */ -export function HeadContent() { - const tags = useTags() +export function HeadContent(props: HeadContentProps) { + const tags = useTags(props.assetCrossOrigin) const hydrated = useHydrated() // Fallback cleanup for hydration mismatch cases diff --git a/packages/solid-router/src/HeadContent.tsx b/packages/solid-router/src/HeadContent.tsx index 8a02f146a70..975fe56dfa5 100644 --- a/packages/solid-router/src/HeadContent.tsx +++ b/packages/solid-router/src/HeadContent.tsx @@ -2,6 +2,11 @@ import { MetaProvider } from '@solidjs/meta' import { For } from 'solid-js' import { Asset } from './Asset' import { useTags } from './headContentUtils' +import type { AssetCrossOriginConfig } from '@tanstack/router-core' + +export interface HeadContentProps { + assetCrossOrigin?: AssetCrossOriginConfig +} /** * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route. @@ -9,8 +14,8 @@ import { useTags } from './headContentUtils' * to ensure it's part of the reactive tree and updates correctly during client-side navigation. * The component uses portals internally to render content into the `` element. */ -export function HeadContent() { - const tags = useTags() +export function HeadContent(props: HeadContentProps) { + const tags = useTags(props.assetCrossOrigin) return ( diff --git a/packages/solid-router/src/headContentUtils.tsx b/packages/solid-router/src/headContentUtils.tsx index 1b30fcc50b7..a1b3e746708 100644 --- a/packages/solid-router/src/headContentUtils.tsx +++ b/packages/solid-router/src/headContentUtils.tsx @@ -1,13 +1,21 @@ import * as Solid from 'solid-js' -import { escapeHtml, replaceEqualDeep } from '@tanstack/router-core' +import { + escapeHtml, + getAssetCrossOrigin, + replaceEqualDeep, + resolveManifestAssetLink, +} from '@tanstack/router-core' import { useRouter } from './useRouter' -import type { RouterManagedTag } from '@tanstack/router-core' +import type { + AssetCrossOriginConfig, + RouterManagedTag, +} from '@tanstack/router-core' /** * Build the list of head/link/meta/script tags to render for active matches. * Used internally by `HeadContent`. */ -export const useTags = () => { +export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const router = useRouter() const nonce = router.options.ssr?.nonce const activeMatches = Solid.createMemo( @@ -116,7 +124,13 @@ export const useTags = () => { (asset) => ({ tag: 'link', - attrs: { ...asset.attrs, nonce }, + attrs: { + ...asset.attrs, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? + asset.attrs?.crossOrigin, + nonce, + }, }) satisfies RouterManagedTag, ) @@ -133,11 +147,15 @@ export const useTags = () => { router.ssr?.manifest?.routes[route.id]?.preloads ?.filter(Boolean) .forEach((preload) => { + const preloadLink = resolveManifestAssetLink(preload) preloadLinks.push({ tag: 'link', attrs: { rel: 'modulepreload', - href: preload, + href: preloadLink.href, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'modulepreload') ?? + preloadLink.crossOrigin, nonce, }, }) diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index d45137a8a3d..0153965de66 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -25,6 +25,7 @@ const createTestManifest = (routeId: string) => ({ routes: { [routeId]: { + preloads: ['/main.js'], assets: [ { tag: 'link', @@ -221,6 +222,63 @@ describe('ssr scripts', () => { ).filter((link) => link.getAttribute('href') === '/main.css'), ).toHaveLength(1) }) + + test('applies assetCrossOrigin to manifest assets and 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), + } + + await router.load() + + render(() => ) + + await waitFor(() => { + expect(document.head.querySelector('link[rel="stylesheet"]')).toBeTruthy() + expect( + document.head.querySelector('link[rel="modulepreload"]'), + ).toBeTruthy() + }) + + expect( + document.head + .querySelector('link[rel="stylesheet"]') + ?.getAttribute('crossorigin'), + ).toBe('use-credentials') + expect( + document.head + .querySelector('link[rel="modulepreload"]') + ?.getAttribute('crossorigin'), + ).toBe('anonymous') + }) }) describe('ssr HeadContent', () => { diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 0e69039adf1..44fd9aa8b5c 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -20,9 +20,10 @@ import { requestHandler } from './request-response' import { getStartManifest } from './router-manifest' import { handleServerAction } from './server-functions-handler' import { + adaptTransformAssetUrlsConfigToTransformAssets, buildManifestWithClientEntry, - resolveTransformConfig, - transformManifestUrls, + resolveTransformAssetsConfig, + transformManifestAssets, } from './transformAssetUrls' import { HEADERS } from './constants' @@ -47,7 +48,8 @@ import type { HandlerCallback } from '@tanstack/router-core/ssr/server' import type { StartManifestWithClientEntry, TransformAssetUrls, - TransformAssetUrlsFn, + TransformAssets, + TransformAssetsFn, } from './transformAssetUrls' type TODO = any @@ -59,7 +61,87 @@ type AnyMiddlewareServerFn = export interface CreateStartHandlerOptions { handler: HandlerCallback /** - * Transform asset URLs at runtime, e.g. to prepend a CDN prefix. + * Transform asset URLs and attributes at runtime, e.g. to prepend a CDN prefix. + * + * **String** — a URL prefix prepended to every asset URL (cached by default): + * ```ts + * createStartHandler({ + * handler: defaultStreamHandler, + * transformAssets: 'https://cdn.example.com', + * }) + * ``` + * + * **Object shorthand** — a URL prefix with optional `crossOrigin`: + * ```ts + * createStartHandler({ + * handler: defaultStreamHandler, + * transformAssets: { + * prefix: 'https://cdn.example.com', + * crossOrigin: 'anonymous', + * }, + * }) + * ``` + * + * `crossOrigin` accepts a single value or a per-kind record: + * ```ts + * transformAssets: { + * prefix: 'https://cdn.example.com', + * crossOrigin: { + * modulepreload: 'anonymous', + * stylesheet: 'use-credentials', + * }, + * } + * ``` + * + * **Callback** — receives `{ kind, url }` and returns either a string URL or + * `{ href, crossOrigin? }` (cached by default — runs once on first request): + * ```ts + * createStartHandler({ + * handler: defaultStreamHandler, + * transformAssets: ({ kind, url }) => { + * const href = `https://cdn.example.com${url}` + * + * if (kind === 'modulepreload') { + * return { href, crossOrigin: 'anonymous' } + * } + * + * return { href } + * }, + * }) + * ``` + * + * **Object** — for explicit cache control: + * ```ts + * createStartHandler({ + * handler: defaultStreamHandler, + * transformAssets: { + * transform: ({ url }) => { + * const region = getRequest().headers.get('x-region') || 'us' + * return { href: `https://cdn-${region}.example.com${url}` } + * }, + * cache: false, + * }, + * }) + * ``` + * + * `kind` is one of `'modulepreload' | 'stylesheet' | 'clientEntry'`. + * `crossOrigin` applies to manifest-managed `` assets. + * + * By default, the transformed manifest is cached after the first request + * (`cache: true`). Set `cache: false` for per-request transforms. + * + * If you're using a cached transform, you can optionally set `warmup: true` + * (object form only) to compute the transformed manifest in the background at + * server startup. + * + * Note: This only transforms URLs managed by TanStack Start's manifest + * (JS preloads, CSS links, and the client entry script). For asset imports + * used directly in components (e.g. `import logo from './logo.svg'`), + * configure Vite's `experimental.renderBuiltUrl` in your vite.config.ts. + */ + transformAssets?: TransformAssets + /** + * @deprecated Use `transformAssets` instead. * * **String** — a URL prefix prepended to every asset URL (cached by default): * ```ts @@ -181,14 +263,14 @@ function getBaseManifest( */ async function resolveManifest( matchedRoutes: ReadonlyArray | undefined, - transformFn: TransformAssetUrlsFn | undefined, + transformFn: TransformAssetsFn | undefined, cache: boolean, ): Promise { const base = await getBaseManifest(matchedRoutes) const computeFinalManifest = async () => { return transformFn - ? await transformManifestUrls(base, transformFn, { clone: !cache }) + ? await transformManifestAssets(base, transformFn, { clone: !cache }) : buildManifestWithClientEntry(base) } @@ -332,7 +414,7 @@ function handlerToMiddleware( * ```ts * export default createStartHandler({ * handler: defaultStreamHandler, - * transformAssetUrls: 'https://cdn.example.com', + * transformAssets: 'https://cdn.example.com', * }) * ``` * @@ -340,10 +422,10 @@ function handlerToMiddleware( * ```ts * export default createStartHandler({ * handler: defaultStreamHandler, - * transformAssetUrls: { + * transformAssets: { * transform: ({ url }) => { * const cdnBase = getRequest().headers.get('x-cdn-base') || '' - * return `${cdnBase}${url}` + * return { href: `${cdnBase}${url}` } * }, * cache: false, * }, @@ -356,28 +438,42 @@ export function createStartHandler( // Normalize the overloaded argument const cb: HandlerCallback = typeof cbOrOptions === 'function' ? cbOrOptions : cbOrOptions.handler + const transformAssetsOption: TransformAssets | undefined = + typeof cbOrOptions === 'function' ? undefined : cbOrOptions.transformAssets const transformAssetUrlsOption: TransformAssetUrls | undefined = typeof cbOrOptions === 'function' ? undefined : cbOrOptions.transformAssetUrls + const transformOption = transformAssetsOption + ? resolveTransformAssetsConfig(transformAssetsOption) + : transformAssetUrlsOption + ? resolveTransformAssetsConfig( + adaptTransformAssetUrlsConfigToTransformAssets( + transformAssetUrlsOption, + ), + ) + : undefined + const warmupTransformManifest = - !!transformAssetUrlsOption && - typeof transformAssetUrlsOption === 'object' && - transformAssetUrlsOption.warmup === true + (!!transformAssetsOption && + typeof transformAssetsOption === 'object' && + 'warmup' in transformAssetsOption && + transformAssetsOption.warmup === true) || + (!!transformAssetUrlsOption && + typeof transformAssetUrlsOption === 'object' && + transformAssetUrlsOption.warmup === true) // Pre-resolve the transform function and cache flag - const resolvedTransformConfig = transformAssetUrlsOption - ? resolveTransformConfig(transformAssetUrlsOption) - : undefined + const resolvedTransformConfig = transformOption const cache = resolvedTransformConfig ? resolvedTransformConfig.cache : true // Memoize a single createTransform() result when caching is enabled. - let cachedCreateTransformPromise: Promise | undefined + let cachedCreateTransformPromise: Promise | undefined const getTransformFn = async ( opts: { warmup: true } | { warmup: false; request: Request }, - ): Promise => { + ): Promise => { if (!resolvedTransformConfig) return undefined if (resolvedTransformConfig.type === 'createTransform') { if (cache) { @@ -408,7 +504,7 @@ export function createStartHandler( const base = await getBaseManifest(undefined) const transformFn = await getTransformFn({ warmup: true }) return transformFn - ? await transformManifestUrls(base, transformFn, { clone: false }) + ? await transformManifestAssets(base, transformFn, { clone: false }) : buildManifestWithClientEntry(base) })() cachedFinalManifestPromise = warmupPromise diff --git a/packages/start-server-core/src/index.tsx b/packages/start-server-core/src/index.tsx index aafbab00021..ca875c8a577 100644 --- a/packages/start-server-core/src/index.tsx +++ b/packages/start-server-core/src/index.tsx @@ -2,11 +2,19 @@ export { createStartHandler } from './createStartHandler' export type { CreateStartHandlerOptions } from './createStartHandler' export type { + TransformAssets, + TransformAssetsFn, + TransformAssetsContext, + TransformAssetsOptions, + TransformAssetsObjectShorthand, + TransformAssetsCrossOriginConfig, + TransformAssetResult, TransformAssetUrls, TransformAssetUrlsFn, TransformAssetUrlsContext, TransformAssetUrlsOptions, AssetUrlType, + TransformAssetKind, } from './transformAssetUrls' export { diff --git a/packages/start-server-core/src/router-manifest.ts b/packages/start-server-core/src/router-manifest.ts index cce836abbe6..d20be26942d 100644 --- a/packages/start-server-core/src/router-manifest.ts +++ b/packages/start-server-core/src/router-manifest.ts @@ -1,5 +1,9 @@ import { buildDevStylesUrl, rootRouteId } from '@tanstack/router-core' -import type { AnyRoute, RouterManagedTag } from '@tanstack/router-core' +import type { + AnyRoute, + ManifestAssetLink, + RouterManagedTag, +} from '@tanstack/router-core' import type { StartManifestWithClientEntry } from './transformAssetUrls' // Pre-computed constant for dev styles URL basepath. @@ -61,7 +65,7 @@ export async function getStartManifest( routes: Object.fromEntries( Object.entries(startManifest.routes).flatMap(([k, v]) => { const result = {} as { - preloads?: Array + preloads?: Array assets?: Array } let hasData = false @@ -82,7 +86,7 @@ export async function getStartManifest( } return { - manifest, + manifest: manifest as StartManifestWithClientEntry['manifest'], clientEntry: startManifest.clientEntry, injectedHeadScripts, } diff --git a/packages/start-server-core/src/transformAssetUrls.ts b/packages/start-server-core/src/transformAssetUrls.ts index 50ab5f4dc9b..283246dd549 100644 --- a/packages/start-server-core/src/transformAssetUrls.ts +++ b/packages/start-server-core/src/transformAssetUrls.ts @@ -1,12 +1,34 @@ -import { rootRouteId } from '@tanstack/router-core' +import { resolveManifestAssetLink, rootRouteId } from '@tanstack/router-core' import type { + AssetCrossOrigin, Awaitable, Manifest, + ManifestAssetLink, RouterManagedTag, } from '@tanstack/router-core' -export type AssetUrlType = 'modulepreload' | 'stylesheet' | 'clientEntry' +export type { AssetCrossOrigin } + +export type TransformAssetKind = 'modulepreload' | 'stylesheet' | 'clientEntry' + +export type AssetUrlType = TransformAssetKind + +export interface TransformAssetsContext { + url: string + kind: TransformAssetKind +} + +export type TransformAssetResult = + | string + | { + href: string + crossOrigin?: AssetCrossOrigin + } + +export type TransformAssetsFn = ( + context: TransformAssetsContext, +) => Awaitable export interface TransformAssetUrlsContext { url: string @@ -41,6 +63,10 @@ export type CreateTransformAssetUrlsFn = ( ctx: CreateTransformAssetUrlsContext, ) => Awaitable +export type CreateTransformAssetsFn = ( + ctx: CreateTransformAssetUrlsContext, +) => Awaitable + type TransformAssetUrlsOptionsBase = { /** * Whether to cache the transformed manifest after the first request. @@ -91,41 +117,131 @@ export type TransformAssetUrlsOptions = transform?: never }) +export type TransformAssetsOptions = + | (TransformAssetUrlsOptionsBase & { + transform: string | TransformAssetsFn + createTransform?: never + }) + | (TransformAssetUrlsOptionsBase & { + createTransform: CreateTransformAssetsFn + transform?: never + }) + export type TransformAssetUrls = | string | TransformAssetUrlsFn | TransformAssetUrlsOptions -export type ResolvedTransformAssetUrlsConfig = +/** + * Per-kind crossOrigin configuration for the object shorthand. + * + * Accepts either a single value applied to all asset kinds, or a per-kind + * record (matching `HeadContent`'s `assetCrossOrigin` shape): + * + * ```ts + * // All assets get the same value + * crossOrigin: 'anonymous' + * + * // Different values per kind + * crossOrigin: { modulepreload: 'anonymous', stylesheet: 'use-credentials' } + * ``` + */ +export type TransformAssetsCrossOriginConfig = + | AssetCrossOrigin + | Partial> + +/** + * Object shorthand for `transformAssets`. Combines a URL prefix with optional + * per-asset `crossOrigin` without needing a callback: + * + * ```ts + * transformAssets: { + * prefix: 'https://cdn.example.com', + * crossOrigin: 'anonymous', + * } + * ``` + */ +export interface TransformAssetsObjectShorthand { + /** URL prefix prepended to every asset URL. */ + prefix: string + /** + * Optional crossOrigin attribute applied to manifest-managed `` assets. + * + * Accepts a single value or a per-kind record. + */ + crossOrigin?: TransformAssetsCrossOriginConfig +} + +export type TransformAssets = + | string + | TransformAssetsFn + | TransformAssetsObjectShorthand + | TransformAssetsOptions + +export type ResolvedTransformAssetsConfig = | { type: 'transform' - transformFn: TransformAssetUrlsFn + transformFn: TransformAssetsFn cache: boolean } | { type: 'createTransform' - createTransform: CreateTransformAssetUrlsFn + createTransform: CreateTransformAssetsFn cache: boolean } -/** - * Resolves a TransformAssetUrls value (string prefix, callback, or options - * object) into a concrete transform function and cache flag. - */ -export function resolveTransformConfig( - transform: TransformAssetUrls, -): ResolvedTransformAssetUrlsConfig { - // String shorthand +let hasWarnedAboutDeprecatedTransformAssetUrls = false + +export function warnDeprecatedTransformAssetUrls() { + if ( + (process.env.NODE_ENV === 'development' || + process.env.TSS_DEV_SERVER === 'true') && + !hasWarnedAboutDeprecatedTransformAssetUrls + ) { + hasWarnedAboutDeprecatedTransformAssetUrls = true + console.warn( + '[TanStack Start] `transformAssetUrls` is deprecated. Use `transformAssets` instead.', + ) + } +} + +function normalizeTransformAssetResult( + result: TransformAssetResult, +): Exclude { + if (typeof result === 'string') { + return { href: result } + } + + return result +} + +function resolveTransformAssetsCrossOrigin( + config: TransformAssetsCrossOriginConfig | undefined, + kind: TransformAssetKind, +): AssetCrossOrigin | undefined { + if (!config) return undefined + if (typeof config === 'string') return config + return config[kind] +} + +function isObjectShorthand( + transform: TransformAssetsObjectShorthand | TransformAssetsOptions, +): transform is TransformAssetsObjectShorthand { + return 'prefix' in transform +} + +export function resolveTransformAssetsConfig( + transform: TransformAssets, +): ResolvedTransformAssetsConfig { if (typeof transform === 'string') { const prefix = transform return { type: 'transform', - transformFn: ({ url }) => `${prefix}${url}`, + transformFn: ({ url }) => ({ href: `${prefix}${url}` }), cache: true, } } - // Callback shorthand if (typeof transform === 'function') { return { type: 'transform', @@ -134,7 +250,25 @@ export function resolveTransformConfig( } } - // Options object + // Object shorthand: { prefix, crossOrigin? } + if (isObjectShorthand(transform)) { + const { prefix, crossOrigin } = transform + return { + type: 'transform', + transformFn: ({ url, kind }) => { + const co = resolveTransformAssetsCrossOrigin(crossOrigin, kind) + const result: { href: string; crossOrigin?: AssetCrossOrigin } = { + href: `${prefix}${url}`, + } + if (co) { + result.crossOrigin = co + } + return result + }, + cache: true, + } + } + if ('createTransform' in transform && transform.createTransform) { return { type: 'createTransform', @@ -145,8 +279,9 @@ export function resolveTransformConfig( const transformFn = typeof transform.transform === 'string' - ? ((({ url }: TransformAssetUrlsContext) => - `${transform.transform}${url}`) as TransformAssetUrlsFn) + ? ((({ url }: TransformAssetsContext) => ({ + href: `${transform.transform}${url}`, + })) as TransformAssetsFn) : transform.transform return { @@ -156,6 +291,48 @@ export function resolveTransformConfig( } } +export function adaptTransformAssetUrlsToTransformAssets( + transformFn: TransformAssetUrlsFn, +): TransformAssetsFn { + return async ({ url, kind }) => ({ + href: await transformFn({ url, type: kind }), + }) +} + +export function adaptTransformAssetUrlsConfigToTransformAssets( + transform: TransformAssetUrls, +): TransformAssets { + warnDeprecatedTransformAssetUrls() + + if (typeof transform === 'string') { + return transform + } + + if (typeof transform === 'function') { + return adaptTransformAssetUrlsToTransformAssets(transform) + } + + if ('createTransform' in transform && transform.createTransform) { + return { + createTransform: async (ctx: CreateTransformAssetUrlsContext) => + adaptTransformAssetUrlsToTransformAssets( + await transform.createTransform(ctx), + ), + cache: transform.cache, + warmup: transform.warmup, + } + } + + return { + transform: + typeof transform.transform === 'string' + ? transform.transform + : adaptTransformAssetUrlsToTransformAssets(transform.transform), + cache: transform.cache, + warmup: transform.warmup, + } +} + export interface StartManifestWithClientEntry { manifest: Manifest clientEntry: string @@ -186,72 +363,88 @@ export function buildClientEntryScriptTag( } } -/** - * Applies a URL transform to every asset URL in the manifest and returns a - * new manifest with a client entry script tag appended to the root route's - * assets. - * - * The source manifest is deep-cloned so the cached original is never mutated. - */ -export function transformManifestUrls( +function assignManifestAssetLink( + link: ManifestAssetLink, + next: { href: string; crossOrigin?: AssetCrossOrigin }, +): ManifestAssetLink { + if (typeof link === 'string') { + return next.crossOrigin ? next : next.href + } + + return next.crossOrigin ? next : { href: next.href } +} + +export async function transformManifestAssets( source: StartManifestWithClientEntry, - transformFn: TransformAssetUrlsFn, + transformFn: TransformAssetsFn, opts?: { - /** When true, clone the source manifest before mutating it. */ clone?: boolean }, ): Promise { - return (async () => { - const manifest = opts?.clone - ? structuredClone(source.manifest) - : source.manifest - - for (const route of Object.values(manifest.routes)) { - // Transform preload URLs (modulepreload) - if (route.preloads) { - route.preloads = await Promise.all( - route.preloads.map((url) => - Promise.resolve(transformFn({ url, type: 'modulepreload' })), - ), - ) - } + const manifest = opts?.clone + ? structuredClone(source.manifest) + : source.manifest + + for (const route of Object.values(manifest.routes)) { + if (route.preloads) { + route.preloads = await Promise.all( + route.preloads.map(async (link) => { + const resolved = resolveManifestAssetLink(link) + const result = normalizeTransformAssetResult( + await transformFn({ + url: resolved.href, + kind: 'modulepreload', + }), + ) - // Transform asset tag URLs - if (route.assets) { - for (const asset of route.assets) { - if (asset.tag === 'link' && asset.attrs?.href) { - asset.attrs.href = await Promise.resolve( - transformFn({ - url: asset.attrs.href, - type: 'stylesheet', - }), - ) + return assignManifestAssetLink(link, { + href: result.href, + crossOrigin: result.crossOrigin, + }) + }), + ) + } + + if (route.assets) { + for (const asset of route.assets) { + if (asset.tag === 'link' && asset.attrs?.href) { + const result = normalizeTransformAssetResult( + await transformFn({ + url: asset.attrs.href, + kind: 'stylesheet', + }), + ) + + asset.attrs.href = result.href + if (result.crossOrigin) { + asset.attrs.crossOrigin = result.crossOrigin + } else { + delete asset.attrs.crossOrigin } } } } + } - // Transform and append the client entry script tag - const transformedClientEntry = await Promise.resolve( - transformFn({ - url: source.clientEntry, - type: 'clientEntry', - }), - ) + const transformedClientEntry = normalizeTransformAssetResult( + await transformFn({ + url: source.clientEntry, + kind: 'clientEntry', + }), + ) - const rootRoute = manifest.routes[rootRouteId] - if (rootRoute) { - rootRoute.assets = rootRoute.assets || [] - rootRoute.assets.push( - buildClientEntryScriptTag( - transformedClientEntry, - source.injectedHeadScripts, - ), - ) - } + const rootRoute = manifest.routes[rootRouteId] + if (rootRoute) { + rootRoute.assets = rootRoute.assets || [] + rootRoute.assets.push( + buildClientEntryScriptTag( + transformedClientEntry.href, + source.injectedHeadScripts, + ), + ) + } - return manifest - })() + return manifest } /** diff --git a/packages/start-server-core/tests/transformAssets.test.ts b/packages/start-server-core/tests/transformAssets.test.ts new file mode 100644 index 00000000000..04561196d61 --- /dev/null +++ b/packages/start-server-core/tests/transformAssets.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest' +import { + adaptTransformAssetUrlsToTransformAssets, + resolveTransformAssetsConfig, + transformManifestAssets, +} from '../src/transformAssetUrls' + +describe('transformAssets', () => { + it('supports string shorthand', async () => { + const config = resolveTransformAssetsConfig('https://cdn.example.com') + + expect(config.type).toBe('transform') + if (config.type !== 'transform') { + throw new Error('expected transform config') + } + + expect( + config.transformFn({ kind: 'modulepreload', url: '/assets/app.js' }), + ).toEqual({ href: 'https://cdn.example.com/assets/app.js' }) + }) + + it('supports object return values with crossOrigin', async () => { + const manifest = await transformManifestAssets( + { + manifest: { + routes: { + __root__: { + preloads: ['/assets/app.js'], + assets: [ + { + tag: 'link', + attrs: { rel: 'stylesheet', href: '/assets/app.css' }, + }, + ], + }, + }, + }, + clientEntry: '/assets/entry.js', + }, + ({ kind, url }) => { + if (kind === 'modulepreload') { + return { + href: `https://cdn.example.com${url}`, + crossOrigin: 'anonymous', + } + } + + return { href: `https://cdn.example.com${url}` } + }, + { clone: true }, + ) + + expect(manifest.routes.__root__?.preloads).toEqual([ + { + href: 'https://cdn.example.com/assets/app.js', + crossOrigin: 'anonymous', + }, + ]) + expect(manifest.routes.__root__?.assets?.[0]).toEqual({ + tag: 'link', + attrs: { + rel: 'stylesheet', + href: 'https://cdn.example.com/assets/app.css', + }, + }) + }) + + it('adapts deprecated transformAssetUrls functions', async () => { + const fn = vi.fn(({ url }: { url: string; type: string }) => `cdn:${url}`) + const adapted = adaptTransformAssetUrlsToTransformAssets(fn) + + await expect( + adapted({ kind: 'stylesheet', url: '/assets/app.css' }), + ).resolves.toEqual({ href: 'cdn:/assets/app.css' }) + expect(fn).toHaveBeenCalledWith({ + type: 'stylesheet', + url: '/assets/app.css', + }) + }) + + it('preserves string preload format when transform returns no crossOrigin', async () => { + const manifest = await transformManifestAssets( + { + manifest: { + routes: { + __root__: { + preloads: ['/assets/app.js'], + assets: [], + }, + }, + }, + clientEntry: '/assets/entry.js', + }, + ({ url }) => ({ href: `https://cdn.example.com${url}` }), + { clone: true }, + ) + + // When original was a string and no crossOrigin is added, should remain a string + expect(manifest.routes.__root__?.preloads?.[0]).toBe( + 'https://cdn.example.com/assets/app.js', + ) + }) + + describe('object shorthand', () => { + it('supports { prefix } — same as string shorthand', () => { + const config = resolveTransformAssetsConfig({ + prefix: 'https://cdn.example.com', + }) + + expect(config.type).toBe('transform') + expect(config.cache).toBe(true) + if (config.type !== 'transform') throw new Error('expected transform') + + expect( + config.transformFn({ kind: 'modulepreload', url: '/assets/app.js' }), + ).toEqual({ href: 'https://cdn.example.com/assets/app.js' }) + }) + + it('supports { prefix, crossOrigin: string } — uniform crossOrigin', () => { + const config = resolveTransformAssetsConfig({ + prefix: 'https://cdn.example.com', + crossOrigin: 'anonymous', + }) + + if (config.type !== 'transform') throw new Error('expected transform') + + expect( + config.transformFn({ kind: 'modulepreload', url: '/assets/app.js' }), + ).toEqual({ + href: 'https://cdn.example.com/assets/app.js', + crossOrigin: 'anonymous', + }) + + expect( + config.transformFn({ kind: 'stylesheet', url: '/assets/app.css' }), + ).toEqual({ + href: 'https://cdn.example.com/assets/app.css', + crossOrigin: 'anonymous', + }) + + // clientEntry gets crossOrigin too (though only href matters for script) + expect( + config.transformFn({ kind: 'clientEntry', url: '/assets/entry.js' }), + ).toEqual({ + href: 'https://cdn.example.com/assets/entry.js', + crossOrigin: 'anonymous', + }) + }) + + it('supports { prefix, crossOrigin: per-kind } — different crossOrigin per kind', () => { + const config = resolveTransformAssetsConfig({ + prefix: 'https://cdn.example.com', + crossOrigin: { + modulepreload: 'anonymous', + stylesheet: 'use-credentials', + }, + }) + + if (config.type !== 'transform') throw new Error('expected transform') + + expect( + config.transformFn({ kind: 'modulepreload', url: '/assets/app.js' }), + ).toEqual({ + href: 'https://cdn.example.com/assets/app.js', + crossOrigin: 'anonymous', + }) + + expect( + config.transformFn({ kind: 'stylesheet', url: '/assets/app.css' }), + ).toEqual({ + href: 'https://cdn.example.com/assets/app.css', + crossOrigin: 'use-credentials', + }) + + // clientEntry not specified in the per-kind record — no crossOrigin + expect( + config.transformFn({ kind: 'clientEntry', url: '/assets/entry.js' }), + ).toEqual({ + href: 'https://cdn.example.com/assets/entry.js', + }) + }) + + it('applies object shorthand crossOrigin to manifest assets', async () => { + const config = resolveTransformAssetsConfig({ + prefix: 'https://cdn.example.com', + crossOrigin: { + modulepreload: 'anonymous', + }, + }) + + if (config.type !== 'transform') throw new Error('expected transform') + + const manifest = await transformManifestAssets( + { + manifest: { + routes: { + __root__: { + preloads: ['/assets/app.js'], + assets: [ + { + tag: 'link', + attrs: { rel: 'stylesheet', href: '/assets/app.css' }, + }, + ], + }, + }, + }, + clientEntry: '/assets/entry.js', + }, + config.transformFn, + { clone: true }, + ) + + expect(manifest.routes.__root__?.preloads).toEqual([ + { + href: 'https://cdn.example.com/assets/app.js', + crossOrigin: 'anonymous', + }, + ]) + + // Stylesheet has no crossOrigin in the per-kind config + expect(manifest.routes.__root__?.assets?.[0]).toEqual({ + tag: 'link', + attrs: { + rel: 'stylesheet', + href: 'https://cdn.example.com/assets/app.css', + }, + }) + }) + }) +}) diff --git a/packages/vue-router/src/HeadContent.dev.tsx b/packages/vue-router/src/HeadContent.dev.tsx index 03a73557697..762cc0e735e 100644 --- a/packages/vue-router/src/HeadContent.dev.tsx +++ b/packages/vue-router/src/HeadContent.dev.tsx @@ -3,6 +3,7 @@ import * as Vue from 'vue' import { Asset } from './Asset' import { useHydrated } from './ClientOnly' import { useTags } from './headContentUtils' +import type { AssetCrossOriginConfig } from '@tanstack/router-core' const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles' @@ -14,8 +15,14 @@ const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles' */ export const HeadContent = Vue.defineComponent({ name: 'HeadContent', - setup() { - const tags = useTags() + props: { + assetCrossOrigin: { + type: [String, Object] as Vue.PropType, + default: undefined, + }, + }, + setup(props) { + const tags = useTags(props.assetCrossOrigin) const hydrated = useHydrated() // Fallback cleanup for hydration mismatch cases diff --git a/packages/vue-router/src/HeadContent.tsx b/packages/vue-router/src/HeadContent.tsx index 0d2b41736ee..c307f866f2f 100644 --- a/packages/vue-router/src/HeadContent.tsx +++ b/packages/vue-router/src/HeadContent.tsx @@ -2,6 +2,11 @@ import * as Vue from 'vue' import { Asset } from './Asset' import { useTags } from './headContentUtils' +import type { AssetCrossOriginConfig } from '@tanstack/router-core' + +export interface HeadContentProps { + assetCrossOrigin?: AssetCrossOriginConfig +} /** * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route. @@ -9,8 +14,14 @@ import { useTags } from './headContentUtils' */ export const HeadContent = Vue.defineComponent({ name: 'HeadContent', - setup() { - const tags = useTags() + props: { + assetCrossOrigin: { + type: [String, Object] as Vue.PropType, + default: undefined, + }, + }, + setup(props) { + const tags = useTags(props.assetCrossOrigin) return () => { return tags().map((tag) => diff --git a/packages/vue-router/src/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx index 1ac40d60bd9..20f6dfb6496 100644 --- a/packages/vue-router/src/headContentUtils.tsx +++ b/packages/vue-router/src/headContentUtils.tsx @@ -1,10 +1,17 @@ import * as Vue from 'vue' -import { escapeHtml } from '@tanstack/router-core' +import { + escapeHtml, + getAssetCrossOrigin, + resolveManifestAssetLink, +} from '@tanstack/router-core' import { useStore } from '@tanstack/vue-store' import { useRouter } from './useRouter' -import type { RouterManagedTag } from '@tanstack/router-core' +import type { + AssetCrossOriginConfig, + RouterManagedTag, +} from '@tanstack/router-core' -export const useTags = () => { +export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const router = useRouter() const matches = useStore( router.stores.activeMatchesSnapshot, @@ -95,11 +102,15 @@ export const useTags = () => { router.ssr?.manifest?.routes[route.id]?.preloads ?.filter(Boolean) .forEach((preload) => { + const preloadLink = resolveManifestAssetLink(preload) preloadMeta.push({ tag: 'link', attrs: { rel: 'modulepreload', - href: preload, + href: preloadLink.href, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'modulepreload') ?? + preloadLink.crossOrigin, }, }) }), @@ -135,7 +146,12 @@ export const useTags = () => { (asset) => ({ tag: 'link', - attrs: { ...asset.attrs }, + attrs: { + ...asset.attrs, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? + asset.attrs?.crossOrigin, + }, }) satisfies RouterManagedTag, ) diff --git a/packages/vue-router/tests/Scripts.test.tsx b/packages/vue-router/tests/Scripts.test.tsx index 445804d527e..c450f9e25ff 100644 --- a/packages/vue-router/tests/Scripts.test.tsx +++ b/packages/vue-router/tests/Scripts.test.tsx @@ -26,6 +26,7 @@ const createTestManifest = (routeId: string) => ({ routes: { [routeId]: { + preloads: ['/main.js'], assets: [ { tag: 'link', @@ -151,6 +152,63 @@ describe('ssr scripts', () => { }) describe('ssr HeadContent', () => { + test('applies assetCrossOrigin to manifest assets and preloads', async () => { + const history = createTestBrowserHistory() + + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } + + await router.load() + + render() + + await waitFor(() => { + expect(document.head.querySelector('link[rel="stylesheet"]')).toBeTruthy() + expect( + document.head.querySelector('link[rel="modulepreload"]'), + ).toBeTruthy() + }) + + expect( + document.head + .querySelector('link[rel="stylesheet"]') + ?.getAttribute('crossorigin'), + ).toBe('use-credentials') + expect( + document.head + .querySelector('link[rel="modulepreload"]') + ?.getAttribute('crossorigin'), + ).toBe('anonymous') + }) + test('derives title, dedupes meta, and allows non-loader HeadContent', async () => { const rootRoute = createRootRoute({ loader: () => From b04c7038e3d2fdf6b95a514629979a1c6bde9851 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 22 Mar 2026 12:45:28 +0100 Subject: [PATCH 2/3] fix --- packages/react-router/src/Match.tsx | 3 +- .../start-manifest-plugin/manifestBuilder.ts | 23 +++++---- .../src/createStartHandler.ts | 35 +++++++++----- .../src/transformAssetUrls.ts | 31 +++++++----- .../tests/transformAssets.test.ts | 48 ++++++++++++++++++- 5 files changed, 103 insertions(+), 37 deletions(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 35f5b1c2f17..ac3a55a5a56 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -64,7 +64,7 @@ export const Match = React.memo(function MatchImpl({ // Subscribe directly to the match store from the pool. // The matchId prop is stable for this component's lifetime (set by Outlet), // and reconcileMatchPool reuses stores for the same matchId. - + const matchStore = router.stores.activeMatchStoresById.get(matchId) if (!matchStore) { if (process.env.NODE_ENV !== 'production') { @@ -338,7 +338,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({ return out } - const matchStore = router.stores.activeMatchStoresById.get(matchId) if (!matchStore) { if (process.env.NODE_ENV !== 'production') { 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 df5c6dbae2b..7df4b450105 100644 --- a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts +++ b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/prefer-for-of */ import { joinURL } from 'ufo' -import { rootRouteId } from '@tanstack/router-core' +import { resolveManifestAssetLink, rootRouteId } from '@tanstack/router-core' import { tsrSplit } from '@tanstack/router-plugin' -import type { RouterManagedTag } from '@tanstack/router-core' +import type { ManifestAssetLink, RouterManagedTag } from '@tanstack/router-core' import type { Rollup } from 'vite' const ROUTER_MANAGED_MODE = 1 @@ -31,6 +31,11 @@ interface ManifestAssetResolvers { getStylesheetAsset: (cssFile: string) => RouterManagedTag } +type DedupePreloadRoute = { + preloads?: Array + children?: Array +} + export function appendUniqueStrings( target: Array | undefined, source: Array, @@ -506,25 +511,27 @@ export function buildRouteManifestRoutes(options: { } export function dedupeNestedRoutePreloads( - route: { preloads?: Array; children?: Array }, - routesById: Record, + route: DedupePreloadRoute, + routesById: Record, seenPreloads = new Set(), ) { let routePreloads = route.preloads if (routePreloads && routePreloads.length > 0) { - let dedupedPreloads: Array | undefined + let dedupedPreloads: Array | undefined for (let i = 0; i < routePreloads.length; i++) { const preload = routePreloads[i]! - if (seenPreloads.has(preload)) { + const preloadHref = resolveManifestAssetLink(preload).href + + if (seenPreloads.has(preloadHref)) { if (dedupedPreloads === undefined) { dedupedPreloads = routePreloads.slice(0, i) } continue } - seenPreloads.add(preload) + seenPreloads.add(preloadHref) if (dedupedPreloads) { dedupedPreloads.push(preload) @@ -549,7 +556,7 @@ export function dedupeNestedRoutePreloads( if (routePreloads) { for (let i = routePreloads.length - 1; i >= 0; i--) { - seenPreloads.delete(routePreloads[i]!) + seenPreloads.delete(resolveManifestAssetLink(routePreloads[i]!).href) } } } diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 44fd9aa8b5c..903c87d0f72 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -445,15 +445,16 @@ export function createStartHandler( ? undefined : cbOrOptions.transformAssetUrls - const transformOption = transformAssetsOption - ? resolveTransformAssetsConfig(transformAssetsOption) - : transformAssetUrlsOption - ? resolveTransformAssetsConfig( - adaptTransformAssetUrlsConfigToTransformAssets( - transformAssetUrlsOption, - ), - ) - : undefined + const transformOption = + transformAssetsOption !== undefined + ? resolveTransformAssetsConfig(transformAssetsOption) + : transformAssetUrlsOption !== undefined + ? resolveTransformAssetsConfig( + adaptTransformAssetUrlsConfigToTransformAssets( + transformAssetUrlsOption, + ), + ) + : undefined const warmupTransformManifest = (!!transformAssetsOption && @@ -467,25 +468,35 @@ export function createStartHandler( // Pre-resolve the transform function and cache flag const resolvedTransformConfig = transformOption const cache = resolvedTransformConfig ? resolvedTransformConfig.cache : true + const shouldCacheCreateTransform = + cache && process.env.TSS_DEV_SERVER !== 'true' - // Memoize a single createTransform() result when caching is enabled. + // Memoize a single createTransform() result when caching is enabled outside + // of the dev server. let cachedCreateTransformPromise: Promise | undefined const getTransformFn = async ( opts: { warmup: true } | { warmup: false; request: Request }, ): Promise => { if (!resolvedTransformConfig) return undefined + if (resolvedTransformConfig.type === 'createTransform') { - if (cache) { + if (shouldCacheCreateTransform) { if (!cachedCreateTransformPromise) { cachedCreateTransformPromise = Promise.resolve( resolvedTransformConfig.createTransform(opts), - ) + ).catch((error) => { + cachedCreateTransformPromise = undefined + throw error + }) } + return cachedCreateTransformPromise } + return resolvedTransformConfig.createTransform(opts) } + return resolvedTransformConfig.transformFn } diff --git a/packages/start-server-core/src/transformAssetUrls.ts b/packages/start-server-core/src/transformAssetUrls.ts index 283246dd549..29709a61f36 100644 --- a/packages/start-server-core/src/transformAssetUrls.ts +++ b/packages/start-server-core/src/transformAssetUrls.ts @@ -12,6 +12,11 @@ export type { AssetCrossOrigin } export type TransformAssetKind = 'modulepreload' | 'stylesheet' | 'clientEntry' +type TransformAssetsShorthandCrossOriginKind = Exclude< + TransformAssetKind, + 'clientEntry' +> + export type AssetUrlType = TransformAssetKind export interface TransformAssetsContext { @@ -148,7 +153,7 @@ export type TransformAssetUrls = */ export type TransformAssetsCrossOriginConfig = | AssetCrossOrigin - | Partial> + | Partial> /** * Object shorthand for `transformAssets`. Combines a URL prefix with optional @@ -217,10 +222,11 @@ function normalizeTransformAssetResult( function resolveTransformAssetsCrossOrigin( config: TransformAssetsCrossOriginConfig | undefined, - kind: TransformAssetKind, + kind: TransformAssetsShorthandCrossOriginKind, ): AssetCrossOrigin | undefined { if (!config) return undefined if (typeof config === 'string') return config + return config[kind] } @@ -253,17 +259,18 @@ export function resolveTransformAssetsConfig( // Object shorthand: { prefix, crossOrigin? } if (isObjectShorthand(transform)) { const { prefix, crossOrigin } = transform + return { type: 'transform', transformFn: ({ url, kind }) => { - const co = resolveTransformAssetsCrossOrigin(crossOrigin, kind) - const result: { href: string; crossOrigin?: AssetCrossOrigin } = { - href: `${prefix}${url}`, - } - if (co) { - result.crossOrigin = co + const href = `${prefix}${url}` + + if (kind === 'clientEntry') { + return { href } } - return result + + const co = resolveTransformAssetsCrossOrigin(crossOrigin, kind) + return co ? { href, crossOrigin: co } : { href } }, cache: true, } @@ -377,13 +384,11 @@ function assignManifestAssetLink( export async function transformManifestAssets( source: StartManifestWithClientEntry, transformFn: TransformAssetsFn, - opts?: { + _opts?: { clone?: boolean }, ): Promise { - const manifest = opts?.clone - ? structuredClone(source.manifest) - : source.manifest + const manifest = structuredClone(source.manifest) for (const route of Object.values(manifest.routes)) { if (route.preloads) { diff --git a/packages/start-server-core/tests/transformAssets.test.ts b/packages/start-server-core/tests/transformAssets.test.ts index 04561196d61..5509ffa423f 100644 --- a/packages/start-server-core/tests/transformAssets.test.ts +++ b/packages/start-server-core/tests/transformAssets.test.ts @@ -101,6 +101,42 @@ describe('transformAssets', () => { ) }) + it('does not mutate the source manifest when clone is false', async () => { + const source = { + manifest: { + routes: { + __root__: { + preloads: ['/assets/app.js'], + assets: [ + { + tag: 'link' as const, + attrs: { rel: 'stylesheet', href: '/assets/app.css' }, + }, + ], + }, + }, + }, + clientEntry: '/assets/entry.js', + } + + const manifest = await transformManifestAssets( + source, + ({ url }) => ({ href: `https://cdn.example.com${url}` }), + { clone: false }, + ) + + expect(manifest.routes.__root__?.preloads?.[0]).toBe( + 'https://cdn.example.com/assets/app.js', + ) + expect(source.manifest.routes.__root__?.preloads?.[0]).toBe( + '/assets/app.js', + ) + expect(source.manifest.routes.__root__?.assets?.[0]).toEqual({ + tag: 'link', + attrs: { rel: 'stylesheet', href: '/assets/app.css' }, + }) + }) + describe('object shorthand', () => { it('supports { prefix } — same as string shorthand', () => { const config = resolveTransformAssetsConfig({ @@ -138,12 +174,10 @@ describe('transformAssets', () => { crossOrigin: 'anonymous', }) - // clientEntry gets crossOrigin too (though only href matters for script) expect( config.transformFn({ kind: 'clientEntry', url: '/assets/entry.js' }), ).toEqual({ href: 'https://cdn.example.com/assets/entry.js', - crossOrigin: 'anonymous', }) }) @@ -180,6 +214,16 @@ describe('transformAssets', () => { }) }) + it('supports empty-string prefix shorthand', () => { + const config = resolveTransformAssetsConfig('') + + if (config.type !== 'transform') throw new Error('expected transform') + + expect( + config.transformFn({ kind: 'modulepreload', url: '/assets/app.js' }), + ).toEqual({ href: '/assets/app.js' }) + }) + it('applies object shorthand crossOrigin to manifest assets', async () => { const config = resolveTransformAssetsConfig({ prefix: 'https://cdn.example.com', From 450dfc94c69ff0abcb915b9f1c72e69eec2c3109 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 22 Mar 2026 13:00:15 +0100 Subject: [PATCH 3/3] fix --- .../src/transformAssetUrls.ts | 7 ++ .../tests/transformAssets.test.ts | 64 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/start-server-core/src/transformAssetUrls.ts b/packages/start-server-core/src/transformAssetUrls.ts index 29709a61f36..5a080297450 100644 --- a/packages/start-server-core/src/transformAssetUrls.ts +++ b/packages/start-server-core/src/transformAssetUrls.ts @@ -413,6 +413,13 @@ export async function transformManifestAssets( if (route.assets) { for (const asset of route.assets) { if (asset.tag === 'link' && asset.attrs?.href) { + const rel = asset.attrs.rel + const relTokens = typeof rel === 'string' ? rel.split(/\s+/) : [] + + if (!relTokens.includes('stylesheet')) { + continue + } + const result = normalizeTransformAssetResult( await transformFn({ url: asset.attrs.href, diff --git a/packages/start-server-core/tests/transformAssets.test.ts b/packages/start-server-core/tests/transformAssets.test.ts index 5509ffa423f..0abcbc3b985 100644 --- a/packages/start-server-core/tests/transformAssets.test.ts +++ b/packages/start-server-core/tests/transformAssets.test.ts @@ -137,6 +137,70 @@ describe('transformAssets', () => { }) }) + it('only treats stylesheet links in route.assets as stylesheet transforms', async () => { + const transformFn = vi.fn(({ url }) => ({ + href: `https://cdn.example.com${url}`, + })) + + const manifest = await transformManifestAssets( + { + manifest: { + routes: { + __root__: { + preloads: [], + assets: [ + { + tag: 'link', + attrs: { rel: 'stylesheet preload', href: '/assets/app.css' }, + }, + { + tag: 'link', + attrs: { rel: 'icon', href: '/favicon.ico' }, + }, + ], + }, + }, + }, + clientEntry: '/assets/entry.js', + }, + transformFn, + { clone: true }, + ) + + expect(transformFn).toHaveBeenCalledWith({ + kind: 'stylesheet', + url: '/assets/app.css', + }) + expect(transformFn).not.toHaveBeenCalledWith({ + kind: 'stylesheet', + url: '/favicon.ico', + }) + expect(manifest.routes.__root__?.assets).toEqual([ + { + tag: 'link', + attrs: { + rel: 'stylesheet preload', + href: 'https://cdn.example.com/assets/app.css', + }, + }, + { + tag: 'link', + attrs: { + rel: 'icon', + href: '/favicon.ico', + }, + }, + { + tag: 'script', + attrs: { + type: 'module', + async: true, + }, + children: 'import("https://cdn.example.com/assets/entry.js")', + }, + ]) + }) + describe('object shorthand', () => { it('supports { prefix } — same as string shorthand', () => { const config = resolveTransformAssetsConfig({