Skip to content

Commit 70eaf7a

Browse files
fix
1 parent 024b837 commit 70eaf7a

3 files changed

Lines changed: 67 additions & 9 deletions

File tree

docs/start/framework/react/guide/cdn-asset-urls.md

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,12 @@ export default createServerEntry({ fetch: handler })
110110

111111
The object form accepts:
112112

113-
| Property | Type | Description |
114-
| ----------------- | ---- | ----------- |
115-
| `transform` | `string \| (asset) => string \| Promise<string>` | A string prefix or callback, same as the shorthand forms above. |
113+
| Property | Type | Description |
114+
| ----------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
115+
| `transform` | `string \| (asset) => string \| Promise<string>` | A string prefix or callback, same as the shorthand forms above. |
116116
| `createTransform` | `(ctx: { warmup: true } \| { warmup: false; request: Request }) => (asset) => string \| Promise<string>` | Async factory that runs once per manifest computation and returns a per-asset transform. Mutually exclusive with `transform`. |
117-
| `cache` | `boolean` | Whether to cache the transformed manifest. Defaults to `true`. |
118-
| `warmup` | `boolean` | When `true`, warms up the cached manifest on server startup (prod only). Defaults to `false`. |
117+
| `cache` | `boolean` | Whether to cache the transformed manifest. Defaults to `true`. |
118+
| `warmup` | `boolean` | When `true`, warms up the cached manifest on server startup (prod only). Defaults to `false`. |
119119

120120
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`:
121121

@@ -165,6 +165,33 @@ This has no effect in development mode, or when `cache: false`.
165165

166166
> **Note:** In development mode (`TSS_DEV_SERVER`), caching is always skipped regardless of the `cache` setting, so you always get fresh manifests.
167167
168+
## Recommended: Set `base: ''` for Client-Side Navigation
169+
170+
`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.
171+
172+
However, when users navigate client-side (e.g., clicking a `<Link>`), 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.
173+
174+
To fix this, set `base: ''` in your Vite config:
175+
176+
```ts
177+
// vite.config.ts
178+
export default defineConfig({
179+
base: '',
180+
// ... plugins, etc.
181+
})
182+
```
183+
184+
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.
185+
186+
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.
187+
188+
| `base` setting | SSR assets (initial load) | Client-side navigation chunks |
189+
| --------------- | ------------------------------ | ------------------------------ |
190+
| `'/'` (default) | CDN (via `transformAssetUrls`) | App server |
191+
| `''` | CDN (via `transformAssetUrls`) | CDN (relative to entry module) |
192+
193+
> **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.
194+
168195
## What This Does NOT Cover
169196

170197
`transformAssetUrls` only rewrites URLs in the TanStack Start manifest — the tags emitted during SSR for preloading and bootstrapping the application.

e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ test.describe('transformAssetUrls with CDN prefix', () => {
103103

104104
// Now load /about and verify the CSS module style is actually applied.
105105
await page.goto('/about')
106-
await page.waitForLoadState('networkidle')
106+
await expect(page.getByTestId('about-card')).toBeVisible()
107107

108108
const bgColor = await page
109109
.getByTestId('about-card')
@@ -119,16 +119,16 @@ test.describe('transformAssetUrls with CDN prefix', () => {
119119
const html = await getSSRHtml(page)
120120

121121
// The client entry script should contain an import() with CDN-prefixed URL
122+
// JSON.stringify produces double quotes; bundler optimisation may use single quotes
122123
const clientEntryMatch = html.match(
123-
/import\("(http:\/\/localhost:\d+\/[^"]+)"\)/,
124+
/import\(["'](http:\/\/localhost:\d+\/[^"']+)["']\)/,
124125
)
125126
expect(clientEntryMatch).toBeTruthy()
126127
expect(clientEntryMatch![1]).toMatch(/^http:\/\/localhost:\d+\//)
127128
})
128129

129130
test('page renders correctly with CDN-served assets', async ({ page }) => {
130131
await page.goto('/')
131-
await page.waitForLoadState('networkidle')
132132

133133
// Page content renders
134134
await expect(page.getByTestId('home-heading')).toHaveText('Welcome Home')
@@ -139,7 +139,7 @@ test.describe('transformAssetUrls with CDN prefix', () => {
139139

140140
test('CSS is applied correctly from CDN', async ({ page }) => {
141141
await page.goto('/')
142-
await page.waitForLoadState('networkidle')
142+
await expect(page.locator('.app-styled')).toBeVisible()
143143

144144
// Verify that the CSS from app.css is actually applied
145145
// The .app-styled class sets background-color to #f0f0f0
@@ -179,17 +179,47 @@ test.describe('transformAssetUrls with CDN prefix', () => {
179179
test('client-side navigation to /about loads split chunk from CDN', async ({
180180
page,
181181
}) => {
182+
const appOrigin = new URL(
183+
test.info().project.use.baseURL || 'http://localhost:3000',
184+
).origin
185+
182186
await page.goto('/')
183187
await page.waitForLoadState('networkidle')
184188

189+
// Start tracking network requests after initial page load so we only
190+
// capture requests triggered by the client-side navigation
191+
const navigationAssetRequests: Array<{
192+
url: string
193+
fromCdn: boolean
194+
}> = []
195+
196+
page.on('request', (request) => {
197+
const url = request.url()
198+
if (/\.(js|css)(\?|$)/.test(url)) {
199+
const origin = new URL(url).origin
200+
navigationAssetRequests.push({ url, fromCdn: origin !== appOrigin })
201+
}
202+
})
203+
185204
await page.getByTestId('link-to-about').click()
186205
await page.waitForURL('**/about')
187206
await expect(page.getByTestId('about-heading')).toHaveText('About')
188207

189208
// Ensure CSS modules were loaded and applied after client navigation
209+
await expect(page.getByTestId('about-card')).toBeVisible()
190210
const bgColor = await page
191211
.getByTestId('about-card')
192212
.evaluate((el) => getComputedStyle(el).backgroundColor)
193213
expect(bgColor).toBe('rgb(255, 243, 196)')
214+
215+
// With base: '', lazy-loaded route chunks should come from the CDN, not the app server.
216+
// Filter to JS requests only (the route chunk import)
217+
const jsRequests = navigationAssetRequests.filter((r) =>
218+
/\.js(\?|$)/.test(r.url),
219+
)
220+
expect(jsRequests.length).toBeGreaterThan(0)
221+
222+
const cdnJsRequests = jsRequests.filter((r) => r.fromCdn)
223+
expect(cdnJsRequests.length).toBeGreaterThan(0)
194224
})
195225
})

e2e/react-start/transform-asset-urls/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tanstackStart } from '@tanstack/react-start/plugin/vite'
44
import viteReact from '@vitejs/plugin-react'
55

66
export default defineConfig({
7+
base: '',
78
server: {
89
port: 3000,
910
},

0 commit comments

Comments
 (0)