diff --git a/.changeset/gentle-pandas-clap.md b/.changeset/gentle-pandas-clap.md new file mode 100644 index 0000000000..e170c7fe85 --- /dev/null +++ b/.changeset/gentle-pandas-clap.md @@ -0,0 +1,15 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +'@tanstack/start-plugin-core': patch +'@tanstack/start-server-core': patch +'@tanstack/react-start': patch +'@tanstack/solid-start': patch +'@tanstack/vue-start': patch +--- + +Add support for Rsbuild client output formats, including module output by default and IIFE output for classic script environments. + +Client entry scripts and preloads are now represented as root route manifest assets, script preloads follow the manifest script format, and script asset cross-origin configuration uses the `script` key. The `transformAssets` script callback context now exposes only `kind: 'script'` and `url`, keeping script format handling internal to manifest rendering. diff --git a/.changeset/slow-badgers-remember.md b/.changeset/slow-badgers-remember.md new file mode 100644 index 0000000000..4a6fb197df --- /dev/null +++ b/.changeset/slow-badgers-remember.md @@ -0,0 +1,10 @@ +--- +'@tanstack/start-plugin-core': patch +'@tanstack/react-start': patch +'@tanstack/solid-start': patch +'@tanstack/vue-start': patch +--- + +Fix Rsbuild server function metadata replay when Rspack restores modules from its persistent cache. + +Server function metadata is now stored on Rspack module build info and replayed from cached modules before resolver modules are rebuilt, preventing warm restarts from losing server function registrations. diff --git a/docs/router/guide/document-head-management.md b/docs/router/guide/document-head-management.md index 381462e20a..61770b3662 100644 --- a/docs/router/guide/document-head-management.md +++ b/docs/router/guide/document-head-management.md @@ -69,14 +69,14 @@ The `` component is **required** to render the head, title, meta, It should be **rendered either in the `` tag of your root layout or as high up in the component tree as possible** if your application doesn't or can't manage the `` tag. For manifest-managed assets, you can also set `crossorigin` values on emitted -`modulepreload` and stylesheet links: +script preload and stylesheet links: ```tsx diff --git a/docs/start/framework/react/guide/cdn-asset-urls.md b/docs/start/framework/react/guide/cdn-asset-urls.md index 183921fc6d..df43906198 100644 --- a/docs/start/framework/react/guide/cdn-asset-urls.md +++ b/docs/start/framework/react/guide/cdn-asset-urls.md @@ -15,9 +15,9 @@ This guide is about asset URL rewriting. For choosing CSS import patterns and co The `transformAssets` option on `createStartHandler` rewrites URLs that Start manages in its SSR manifest: -- `` tags for JavaScript preloads +- JavaScript preload links (`` for module output, or `` for IIFE output) - `` tags for manifest-managed CSS -- The client entry module URL +- The client entry script URL - `url(...)` and `@import` URLs inside [inlined CSS](./css-styling#inline-route-css-in-production) when CSS URL templates are enabled It does not rewrite every URL in your app. In particular, it does not rewrite arbitrary route `head().links` entries, including CSS imported with `?url` and returned from route `head()` functions. See [What This Does Not Rewrite](#what-this-does-not-rewrite) for the main exclusions. @@ -75,7 +75,7 @@ export default createServerEntry({ fetch: handler }) transformAssets: { prefix: 'https://cdn.example.com', crossOrigin: { - modulepreload: 'anonymous', + script: 'anonymous', stylesheet: 'use-credentials', }, } @@ -94,13 +94,13 @@ or: ```tsx ``` -If both `transformAssets` and `assetCrossOrigin` set a cross-origin value, `assetCrossOrigin` overrides the value from `transformAssets`. `assetCrossOrigin` only applies to manifest-managed `modulepreload` and stylesheet links, not arbitrary links returned from route `head()` functions. +If both `transformAssets` and `assetCrossOrigin` set a cross-origin value, `assetCrossOrigin` overrides the value from `transformAssets`. `assetCrossOrigin` only applies to manifest-managed script and stylesheet links, not arbitrary links returned from route `head()` functions. ## Use a Callback for Per-Asset Logic @@ -119,7 +119,7 @@ const handler = createStartHandler({ transformAssets: (asset) => { const href = `https://cdn.example.com${asset.url}` - if (asset.kind === 'modulepreload') { + if (asset.kind === 'script') { return { href, crossOrigin: 'anonymous', @@ -135,16 +135,15 @@ export default createServerEntry({ fetch: handler }) The `kind` field tells you which asset URL is being transformed. -| `kind` | Description | -| ----------------- | ---------------------------------------------- | -| `'modulepreload'` | JavaScript module preload URL | -| `'stylesheet'` | Manifest-managed CSS stylesheet URL | -| `'clientEntry'` | Client entry module URL | -| `'css-url'` | `url(...)` or `@import` URL inside inlined CSS | +| `kind` | Description | +| -------------- | ---------------------------------------------- | +| `'script'` | JavaScript preload or client entry script URL | +| `'stylesheet'` | Manifest-managed CSS stylesheet URL | +| `'css-url'` | `url(...)` or `@import` URL inside inlined CSS | For `kind === 'css-url'`, the context also includes `stylesheetHref`, which is the manifest stylesheet href whose CSS content is being inlined. -`crossOrigin` applies to manifest-managed link tags. For the client entry and CSS-internal URLs, returning `{ href }` is equivalent to returning a string. +`crossOrigin` applies to manifest-managed script and stylesheet tags. For CSS-internal URLs, returning `{ href }` is equivalent to returning a string. By default, callback results are cached after the first request in production. Use the object form with `cache: false` only when the transform depends on per-request data. @@ -171,7 +170,7 @@ const handler = createStartHandler({ ? 'https://cdn-eu.example.com' : 'https://cdn-us.example.com' - if (kind === 'modulepreload') { + if (kind === 'script') { return { href: `${cdnBase}${url}`, crossOrigin: 'anonymous', @@ -210,7 +209,7 @@ transformAssets: { const cdnBase = await fetchCdnBaseForRegion(region) return (asset) => { - if (asset.kind === 'modulepreload') { + if (asset.kind === 'script') { return { href: `${cdnBase}${asset.url}`, crossOrigin: 'anonymous', @@ -320,7 +319,7 @@ Warmup has no effect in development mode or when `cache: false`. ## Use Relative Vite Asset Paths for Client Navigation -`transformAssets` rewrites the URLs in the SSR HTML: modulepreload hints, stylesheet links, and the client entry module. This means the browser's initial page load can fetch those assets from the CDN. +`transformAssets` rewrites the URLs in the SSR HTML: script preload hints, stylesheet links, and the client entry script. This means the browser's initial page load can fetch those assets from the CDN. When users navigate client-side, TanStack Router lazy-loads route chunks using `import()` calls with paths baked in by the bundler. With Vite's default `base: '/'`, those paths are absolute, such as `/assets/about-abc123.js`, and resolve against the app server origin instead of the CDN. @@ -336,7 +335,7 @@ export default defineConfig({ }) ``` -With `base: ''`, the client entry module can be loaded from the CDN by `transformAssets`, and relative `import()` calls resolve against that same CDN origin. This keeps lazy-loaded route chunks on the CDN during client-side navigation. +With `base: ''`, the client entry script can be loaded from the CDN by `transformAssets`, and relative `import()` calls resolve against that same CDN origin. This keeps lazy-loaded route chunks on the CDN during client-side navigation. Using an empty string rather than `'./'` is important. Both produce relative client-side imports, but `base: ''` preserves the root-relative paths in the SSR manifest so `transformAssets` can prepend the CDN origin correctly. diff --git a/docs/start/framework/react/guide/early-hints.md b/docs/start/framework/react/guide/early-hints.md index 2c63bd5289..da633a84d7 100644 --- a/docs/start/framework/react/guide/early-hints.md +++ b/docs/start/framework/react/guide/early-hints.md @@ -165,6 +165,7 @@ Static Early Hints are collected from the final Start manifest resolved for the - CDN URL rewrites are reflected in Early Hints. - `crossOrigin` returned from `transformAssets` is reflected in Early Hints. +- JavaScript hints follow the client output format: `modulepreload` for module output, or `preload; as=script` for IIFE output. - Per-request transforms with `cache: false` are reflected in Early Hints for that request. - Inlined CSS assets are skipped when Start's [CSS inlining](./css-styling#inline-route-css-in-production) build option inlines them into the HTML. diff --git a/e2e/react-router/basic-file-based/src/routeTree.gen.ts b/e2e/react-router/basic-file-based/src/routeTree.gen.ts index daf304c064..7ee0051549 100644 --- a/e2e/react-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/react-router/basic-file-based/src/routeTree.gen.ts @@ -780,9 +780,9 @@ const NonNestedDeepBazBarFooQuxRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/fullpath-test': typeof FullpathTestLayoutRouteRouteWithChildren + '/fullpath-test': typeof FullpathTestRouteRouteWithChildren '/non-nested': typeof NonNestedRouteRouteWithChildren - '/pathless-layout': typeof PathlessLayoutLayoutRouteRouteWithChildren + '/pathless-layout': typeof PathlessLayoutRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren '/anchor': typeof AnchorRoute diff --git a/e2e/react-start/basic/package.json b/e2e/react-start/basic/package.json index eaf648c6ab..4e0fd86ab8 100644 --- a/e2e/react-start/basic/package.json +++ b/e2e/react-start/basic/package.json @@ -71,6 +71,14 @@ "toolchain": "rsbuild", "mode": "ssr" }, + { + "toolchain": "rsbuild", + "mode": "ssr", + "name": "iife", + "env": { + "TSS_RSB_CLIENT_OUTPUT": "iife" + } + }, { "toolchain": "rsbuild", "mode": "spa" diff --git a/e2e/react-start/basic/playwright.config.ts b/e2e/react-start/basic/playwright.config.ts index 5e46a2b1c7..4d92fc032d 100644 --- a/e2e/react-start/basic/playwright.config.ts +++ b/e2e/react-start/basic/playwright.config.ts @@ -57,6 +57,9 @@ export default defineConfig({ PORT: String(PORT), E2E_DIST_DIR: distDir, E2E_PORT_KEY: e2ePortKey, + ...(process.env.TSS_RSB_CLIENT_OUTPUT + ? { TSS_RSB_CLIENT_OUTPUT: process.env.TSS_RSB_CLIENT_OUTPUT } + : {}), }, }, diff --git a/e2e/react-start/basic/start-mode-config.ts b/e2e/react-start/basic/start-mode-config.ts index 3cb6db2658..efc5862eea 100644 --- a/e2e/react-start/basic/start-mode-config.ts +++ b/e2e/react-start/basic/start-mode-config.ts @@ -1,6 +1,18 @@ import { isPrerender } from './tests/utils/isPrerender' import { isSpaMode } from './tests/utils/isSpaMode' +const rsbuildClientOutput: 'module' | 'iife' | undefined = (() => { + const output = process.env.TSS_RSB_CLIENT_OUTPUT + + if (output === undefined) return undefined + if (output === 'module') return 'module' + if (output === 'iife') return 'iife' + + throw new Error( + `Invalid TSS_RSB_CLIENT_OUTPUT: ${output}. Expected "module" or "iife".`, + ) +})() + export function getStartModeConfig() { return { spa: isSpaMode @@ -28,5 +40,12 @@ export function getStartModeConfig() { maxRedirects: 100, } : undefined, + rsbuild: rsbuildClientOutput + ? { + client: { + output: rsbuildClientOutput, + }, + } + : undefined, } } diff --git a/e2e/react-start/basic/tests/client-output.spec.ts b/e2e/react-start/basic/tests/client-output.spec.ts new file mode 100644 index 0000000000..41cf21870d --- /dev/null +++ b/e2e/react-start/basic/tests/client-output.spec.ts @@ -0,0 +1,32 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test.skip( + process.env.TSS_RSB_CLIENT_OUTPUT !== 'iife', + 'IIFE client output assertions only run in rsbuild/ssr/iife mode', +) + +test('SSR HTML emits IIFE client scripts and classic script preloads', async ({ + page, +}) => { + const response = await page.goto('/posts') + const html = await response!.text() + + expect(html).not.toContain('rel="modulepreload"') + expect(html).toMatch(/]+rel="preload"[^>]+as="script"/) + + const clientEntry = html.match( + /]+src="([^"]*\/static\/js\/index[^"]*)"[^>]*>/, + ) + expect(clientEntry).toBeTruthy() + expect(clientEntry![0]).toContain('async') + expect(clientEntry![0]).not.toContain('type="module"') + + await expect( + page.getByRole('link', { name: 'sunt aut facere repe' }), + ).toBeVisible() + + await page.getByRole('link', { name: 'sunt aut facere repe' }).click() + + await expect(page.getByRole('heading')).toContainText('sunt aut facere') +}) diff --git a/e2e/react-start/csp/tests/csp.spec.ts b/e2e/react-start/csp/tests/csp.spec.ts index 5bdfd93e9b..ef6b2dadc6 100644 --- a/e2e/react-start/csp/tests/csp.spec.ts +++ b/e2e/react-start/csp/tests/csp.spec.ts @@ -1,6 +1,21 @@ -import { expect } from '@playwright/test' +import { expect, type Page } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' +async function getRawHtml(page: Page) { + let rawHtml = '' + + await page.route('/', async (route) => { + const response = await route.fetch() + rawHtml = await response.text() + await route.fulfill({ response, body: rawHtml }) + }) + + const response = await page.goto('/') + await page.unrouteAll({ behavior: 'ignoreErrors' }) + + return { rawHtml, response } +} + test('CSP header is set with nonce', async ({ page }) => { const response = await page.goto('/') const csp = response?.headers()['content-security-policy'] @@ -21,9 +36,10 @@ test('Inline styles have nonce attribute', async ({ page }) => { }) test('External script has nonce attribute', async ({ page }) => { - await page.goto('/') - const externalScript = page.locator('script[src="/external.js"]') - await expect(externalScript).toHaveAttribute('nonce') + const { rawHtml } = await getRawHtml(page) + expect(rawHtml).toMatch( + /]*\bsrc="\/external\.js")(?=[^>]*\bnonce="[^"]+")[^>]*>/, + ) }) test('External stylesheet has nonce attribute', async ({ page }) => { @@ -34,15 +50,7 @@ test('External stylesheet has nonce attribute', async ({ page }) => { test('Nonces match between header and elements', async ({ page }) => { // Intercept the HTML response to get raw content before browser strips nonces - let rawHtml = '' - await page.route('/', async (route) => { - const response = await route.fetch() - rawHtml = await response.text() - await route.fulfill({ response }) - }) - - const response = await page.goto('/') - await page.unrouteAll({ behavior: 'ignoreErrors' }) + const { rawHtml, response } = await getRawHtml(page) const csp = response?.headers()['content-security-policy'] || '' diff --git a/e2e/react-start/dev-ssr-styles/package.json b/e2e/react-start/dev-ssr-styles/package.json index 3090ece5f0..60a2ab9e0a 100644 --- a/e2e/react-start/dev-ssr-styles/package.json +++ b/e2e/react-start/dev-ssr-styles/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@playwright/test": "^1.50.1", + "@tanstack/router-core": "workspace:*", "@tanstack/router-e2e-utils": "workspace:*", "@types/node": "^22.10.2", "@types/react": "^19.0.8", diff --git a/e2e/react-start/dev-ssr-styles/tests/app.spec.ts b/e2e/react-start/dev-ssr-styles/tests/app.spec.ts index e10f9f73c0..cb1ba0b29b 100644 --- a/e2e/react-start/dev-ssr-styles/tests/app.spec.ts +++ b/e2e/react-start/dev-ssr-styles/tests/app.spec.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test' +import { DEV_STYLES_ATTR } from '@tanstack/router-core' import { test } from '@tanstack/router-e2e-utils' import { ssrStylesMode } from '../env' @@ -29,9 +30,9 @@ test.describe(`dev.ssrStyles (mode=${ssrStylesMode})`, () => { expect(response.ok()).toBeTruthy() const html = await response.text() - // Should have a link tag with data-tanstack-router-dev-styles - expect(html).toContain('data-tanstack-router-dev-styles') + // Should have a dev styles link tag. expect(html).toContain('/@tanstack-start/styles.css') + expect(html).toContain(DEV_STYLES_ATTR) }) test('dev styles link uses vite base (/) as basepath prefix', async ({ @@ -75,9 +76,9 @@ test.describe(`dev.ssrStyles (mode=${ssrStylesMode})`, () => { expect(response.ok()).toBeTruthy() const html = await response.text() - // Should NOT have a link tag with data-tanstack-router-dev-styles - expect(html).not.toContain('data-tanstack-router-dev-styles') + // Should NOT have a dev styles link tag. expect(html).not.toContain('/@tanstack-start/styles.css') + expect(html).not.toContain(DEV_STYLES_ATTR) }) test('page still renders without dev styles', async ({ page }) => { @@ -100,9 +101,6 @@ test.describe(`dev.ssrStyles (mode=${ssrStylesMode})`, () => { expect(response.ok()).toBeTruthy() const html = await response.text() - // Should have a link tag with data-tanstack-router-dev-styles - expect(html).toContain('data-tanstack-router-dev-styles') - // The dev styles URL should use /custom-styles/ as the basepath prefix const match = html.match( /href="([^"]*@tanstack-start\/styles\.css[^"]*)"/, @@ -110,6 +108,7 @@ test.describe(`dev.ssrStyles (mode=${ssrStylesMode})`, () => { expect(match).toBeTruthy() const href = match![1] expect(href).toMatch(/^\/custom-styles\/@tanstack-start\/styles\.css/) + expect(html).toContain(DEV_STYLES_ATTR) }) test.describe('with JavaScript disabled', () => { diff --git a/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts b/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts index 2d543c5406..d700863d1e 100644 --- a/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts +++ b/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts @@ -18,6 +18,11 @@ async function clickAndExpectCount(page: Page, id: string, count: string) { await expect(page.getByTestId(`${id}-count`)).toHaveText(count) } +async function gotoWithoutPointerIntent(page: Page, path: string) { + await page.mouse.move(0, 0) + await page.goto(path) +} + async function waitForHydrateMarkerToMount(page: Page, id: string) { await page.waitForFunction((testId) => { const button = document.querySelector(`[data-testid="${testId}-button"]`) @@ -30,7 +35,7 @@ test.describe('RSC deferred hydration', () => { test('server component renders a client Hydrate island that hydrates on interaction', async ({ page, }) => { - await page.goto('/server-client') + await gotoWithoutPointerIntent(page, '/server-client') await expect(page.getByTestId('server-client-rsc')).toContainText( 'Server component renders a deferred client island', @@ -40,6 +45,7 @@ test.describe('RSC deferred hydration', () => { ) await expectUnhydrated(page, 'server-client') + await page.mouse.move(0, 0) await page.getByTestId('server-client-button').hover() await clickAndExpectCount(page, 'server-client', '1') }) @@ -47,7 +53,7 @@ test.describe('RSC deferred hydration', () => { test('composite server component can wrap an interaction Hydrate client island', async ({ page, }) => { - await page.goto('/composite') + await gotoWithoutPointerIntent(page, '/composite') await expect(page.getByTestId('composite-rsc')).toContainText( 'Server shell, client Hydrate slot', @@ -58,6 +64,7 @@ test.describe('RSC deferred hydration', () => { await expectUnhydrated(page, 'composite-interaction') await waitForHydrateMarkerToMount(page, 'composite-interaction') + await page.mouse.move(0, 0) await page.getByTestId('composite-interaction-button').hover() await clickAndExpectCount(page, 'composite-interaction', '1') }) @@ -65,7 +72,7 @@ test.describe('RSC deferred hydration', () => { test('server component can render a CSS module Hydrate client island', async ({ page, }) => { - await page.goto('/css') + await gotoWithoutPointerIntent(page, '/css') await expect(page.getByTestId('css-rsc')).toContainText( 'CSS module Hydrate boundary', @@ -74,9 +81,12 @@ test.describe('RSC deferred hydration', () => { 'font-weight', '900', ) - await expectUnhydrated(page, 'css-nested') await waitForHydrateMarkerToMount(page, 'css-nested') - await page.getByTestId('css-nested-button').hover() - await clickAndExpectCount(page, 'css-nested', '1') + await page.getByTestId('css-nested-button').click() + await expect(page.getByTestId('css-nested-button')).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await expect(page.getByTestId('css-nested-count')).toHaveText('1') }) }) diff --git a/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts b/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts index 39b2f3e8d2..737695a0cf 100644 --- a/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts +++ b/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts @@ -8,8 +8,9 @@ test('SSR scroll restoration uses a custom restoration key', async ({ const customKeyScrollY = 650 const historyKeyScrollY = 80 - await page.goto('/') - await page.evaluate( + // Seed storage during document initialization so a hydrated previous page + // cannot persist an empty in-memory scroll cache over these entries. + await page.addInitScript( ({ customKeyScrollY, historyKeyScrollY, storageKey }) => { window.sessionStorage.setItem( storageKey, @@ -22,13 +23,11 @@ test('SSR scroll restoration uses a custom restoration key', async ({ }, }), ) + window.history.replaceState({ __TSR_key: 'history-key' }, '') }, { customKeyScrollY, historyKeyScrollY, storageKey }, ) - await page.addInitScript(() => { - window.history.replaceState({ __TSR_key: 'history-key' }, '') - }) await page.route(/\/assets\/.*\.js$/, (route) => route.abort()) await page.goto('/ssr-scroll-key', { waitUntil: 'domcontentloaded' }) diff --git a/e2e/react-start/server-functions/package.json b/e2e/react-start/server-functions/package.json index 2f2c698e2c..c7f8876f63 100644 --- a/e2e/react-start/server-functions/package.json +++ b/e2e/react-start/server-functions/package.json @@ -13,7 +13,8 @@ "build:rsbuild": "rsbuild build && tsc --noEmit", "preview": "vite preview", "start": "node server.js", - "test:e2e": "rm -rf port*.txt && pnpm build && playwright test --project=chromium" + "test:e2e": "rm -rf port*.txt && pnpm build && playwright test --project=chromium && pnpm run test:e2e:rsbuild-cache", + "test:e2e:rsbuild-cache": "playwright test -c playwright.rsbuild-cache.config.ts --project=chromium" }, "dependencies": { "@tanstack/react-query": "^5.90.0", diff --git a/e2e/react-start/server-functions/playwright.rsbuild-cache.config.ts b/e2e/react-start/server-functions/playwright.rsbuild-cache.config.ts new file mode 100644 index 0000000000..efa7357cab --- /dev/null +++ b/e2e/react-start/server-functions/playwright.rsbuild-cache.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + testMatch: /rsbuild-cache\.spec\.ts/, + timeout: 120_000, + workers: 1, + reporter: [['line']], + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/server-functions/rsbuild.config.ts b/e2e/react-start/server-functions/rsbuild.config.ts index 23072b6c1b..e5efd7624b 100644 --- a/e2e/react-start/server-functions/rsbuild.config.ts +++ b/e2e/react-start/server-functions/rsbuild.config.ts @@ -13,4 +13,7 @@ export default defineConfig({ root: outDir, }, }, + performance: { + buildCache: process.env.E2E_RSBUILD_BUILD_CACHE !== 'false', + }, }) diff --git a/e2e/react-start/server-functions/tests/rsbuild-cache.spec.ts b/e2e/react-start/server-functions/tests/rsbuild-cache.spec.ts new file mode 100644 index 0000000000..32a2f406e6 --- /dev/null +++ b/e2e/react-start/server-functions/tests/rsbuild-cache.spec.ts @@ -0,0 +1,390 @@ +import { spawn } from 'node:child_process' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { expect, test } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from '../package.json' with { type: 'json' } +import type { ChildProcess } from 'node:child_process' +import type { Page, Request } from '@playwright/test' + +const appDir = resolve(import.meta.dirname, '..') +const cacheDir = resolve(appDir, 'node_modules/.cache/rspack') +const consistentRoutePath = resolve(appDir, 'src/routes/consistent.tsx') +const noServerFunctionsConsistentRouteSource = `import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/consistent')({ + component: ConsistentServerFnCalls, +}) + +function ConsistentServerFnCalls() { + return ( +
+ Server functions removed by watch test +
+ ) +} +` + +interface ServerFnRequest { + method: string + url: string +} + +interface DevServer { + child: ChildProcess + logs: Array +} + +function startRsbuildDevServer( + port: number, + buildCacheEnabled = true, +): DevServer { + const logs: Array = [] + const child = spawn( + 'pnpm', + ['exec', 'rsbuild', 'dev', '--port', String(port)], + { + cwd: appDir, + detached: true, + env: { + ...process.env, + PORT: String(port), + VITE_SERVER_PORT: String(port), + VITE_NODE_ENV: 'test', + E2E_RSBUILD_BUILD_CACHE: buildCacheEnabled ? 'true' : 'false', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + + child.stdout?.on('data', (chunk: Buffer) => logs.push(chunk.toString())) + child.stderr?.on('data', (chunk: Buffer) => logs.push(chunk.toString())) + + return { child, logs } +} + +function killDevServer(devServer: DevServer, signal: NodeJS.Signals) { + const pid = devServer.child.pid + + try { + if (pid) { + process.kill(-pid, signal) + return + } + } catch { + // Fall back to killing the direct child below. + } + + devServer.child.kill(signal) +} + +async function waitForHttpOk(devServer: DevServer, url: string): Promise { + const start = Date.now() + + while (Date.now() - start < 60_000) { + if (devServer.child.exitCode !== null) { + throw new Error( + `Rsbuild dev server exited before ${url} was ready.\n${devServer.logs.join('')}`, + ) + } + + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(1000), + }) + + if (response.status >= 200 && response.status < 400) { + return + } + } catch { + // Wait until the dev server starts accepting requests. + } + + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + throw new Error( + `Timed out waiting for Rsbuild dev server at ${url}.\n${devServer.logs.join('')}`, + ) +} + +async function stopDevServer(devServer: DevServer): Promise { + if (devServer.child.exitCode !== null || devServer.child.killed) { + return + } + + await new Promise((resolve) => { + let settled = false + let killTimer: ReturnType | undefined + let doneTimer: ReturnType | undefined + + const done = () => { + if (settled) { + return + } + settled = true + if (killTimer) { + clearTimeout(killTimer) + } + if (doneTimer) { + clearTimeout(doneTimer) + } + resolve() + } + + devServer.child.once('exit', done) + devServer.child.once('error', done) + + try { + killDevServer(devServer, 'SIGTERM') + } catch { + done() + return + } + + killTimer = setTimeout(() => { + try { + killDevServer(devServer, 'SIGKILL') + } catch { + // The process may have already exited. + } + + doneTimer = setTimeout(done, 500) + }, 3000) + }) +} + +async function waitForRetry( + devServer: DevServer, + description: string, + action: () => Promise, +): Promise { + const start = Date.now() + let lastError: unknown + + while (Date.now() - start < 30_000) { + if (devServer.child.exitCode !== null) { + throw new Error( + `Rsbuild dev server exited while waiting for ${description}.\n${devServer.logs.join('')}`, + ) + } + + try { + return await action() + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + + throw new Error( + `Timed out waiting for ${description}.\n${lastError instanceof Error ? lastError.stack || lastError.message : String(lastError)}\n\nRsbuild logs:\n${devServer.logs.join('')}`, + ) +} + +async function captureServerFnRequests( + page: Page, + action: () => Promise, +): Promise> { + const requests: Array = [] + const listener = (request: Request) => { + if (request.url().includes('/_serverFn/')) { + requests.push({ + method: request.method(), + url: request.url(), + }) + } + } + + page.on('request', listener) + try { + await action() + } finally { + page.off('request', listener) + } + + return requests +} + +async function exerciseServerFunctions( + page: Page, + baseURL: string, + expectedUsername = 'TEST', +) { + const response = await page.goto(`${baseURL}/consistent`, { + waitUntil: 'networkidle', + }) + expect(response?.ok()).toBe(true) + + const expectedLocator = page.getByTestId( + 'expected-consistent-server-fns-result', + ) + await expect(expectedLocator).toContainText( + JSON.stringify({ payload: { username: expectedUsername } }), + ) + const expected = (await expectedLocator.textContent()) || '' + + await page.getByTestId('test-consistent-server-fn-calls-btn').click() + await page.waitForLoadState('networkidle') + + await expect(page.getByTestId('cons_serverGetFn1-response')).toContainText( + expected, + ) + await expect(page.getByTestId('cons_getFn1-response')).toContainText(expected) + await expect(page.getByTestId('cons_serverPostFn1-response')).toContainText( + expected, + ) + await expect(page.getByTestId('cons_postFn1-response')).toContainText( + expected, + ) +} + +async function exerciseServerFunctionsWithLogs( + page: Page, + baseURL: string, + devServer: DevServer, + expectedUsername = 'TEST', +) { + try { + await exerciseServerFunctions(page, baseURL, expectedUsername) + } catch (error) { + throw new Error( + `${error instanceof Error ? error.stack || error.message : String(error)}\n\nRsbuild logs:\n${devServer.logs.join('')}`, + ) + } +} + +async function waitForStaticConsistentRoute( + page: Page, + baseURL: string, + devServer: DevServer, +) { + await waitForRetry(devServer, 'static consistent route update', async () => { + const response = await page.goto( + `${baseURL}/consistent?watch=${Date.now()}`, + { + waitUntil: 'networkidle', + }, + ) + expect(response?.ok()).toBe(true) + await expect(page.getByTestId('watch-no-server-functions')).toContainText( + 'Server functions removed by watch test', + { timeout: 1000 }, + ) + }) +} + +test('rsbuild build cache warm restart keeps server function registry', async ({ + page, +}) => { + test.setTimeout(120_000) + + rmSync(cacheDir, { recursive: true, force: true }) + + const port = await getTestServerPort(`${packageJson.name}-rsbuild-cache`) + const baseURL = `http://localhost:${port}` + let devServer = startRsbuildDevServer(port) + + try { + await waitForHttpOk(devServer, baseURL) + await exerciseServerFunctionsWithLogs(page, baseURL, devServer) + } finally { + await stopDevServer(devServer) + } + + expect(existsSync(cacheDir)).toBe(true) + + devServer = startRsbuildDevServer(port) + + try { + await waitForHttpOk(devServer, baseURL) + await exerciseServerFunctionsWithLogs(page, baseURL, devServer) + + expect(devServer.logs.join('')).not.toContain( + 'Server function info not found', + ) + } finally { + await stopDevServer(devServer) + } +}) + +test('rsbuild without build cache keeps server function registry', async ({ + page, +}) => { + test.setTimeout(120_000) + + rmSync(cacheDir, { recursive: true, force: true }) + + const port = await getTestServerPort(`${packageJson.name}-rsbuild-no-cache`) + const baseURL = `http://localhost:${port}` + const devServer = startRsbuildDevServer(port, false) + + try { + await waitForHttpOk(devServer, baseURL) + await exerciseServerFunctionsWithLogs(page, baseURL, devServer) + + expect(devServer.logs.join('')).not.toContain( + 'Server function info not found', + ) + } finally { + await stopDevServer(devServer) + } +}) + +test('rsbuild watch updates and removes server function metadata', async ({ + page, +}) => { + test.setTimeout(120_000) + + const originalSource = readFileSync(consistentRoutePath, 'utf8') + const updatedSource = originalSource.replaceAll("'TEST'", "'WATCH'") + rmSync(cacheDir, { recursive: true, force: true }) + + const port = await getTestServerPort(`${packageJson.name}-rsbuild-watch`) + const baseURL = `http://localhost:${port}` + const devServer = startRsbuildDevServer(port) + + try { + await waitForHttpOk(devServer, baseURL) + await exerciseServerFunctionsWithLogs(page, baseURL, devServer) + + writeFileSync(consistentRoutePath, updatedSource) + const serverFnRequests = await waitForRetry( + devServer, + 'updated server function metadata', + async () => { + return await captureServerFnRequests(page, async () => { + await exerciseServerFunctionsWithLogs( + page, + baseURL, + devServer, + 'WATCH', + ) + }) + }, + ) + const staleGetRequest = serverFnRequests.find( + (request) => request.method === 'GET', + ) + expect(staleGetRequest).toBeDefined() + + writeFileSync(consistentRoutePath, noServerFunctionsConsistentRouteSource) + await waitForStaticConsistentRoute(page, baseURL, devServer) + + const staleResult = await page.evaluate(async (url) => { + const response = await fetch(url) + return { + ok: response.ok, + status: response.status, + text: await response.text(), + } + }, staleGetRequest!.url) + + expect(staleResult.ok).toBe(false) + expect(staleResult.status).toBeGreaterThanOrEqual(400) + expect(staleResult.text).not.toContain('WATCH') + } finally { + writeFileSync(consistentRoutePath, originalSource) + await stopDevServer(devServer) + } +}) diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts index 9b23cb8a2a..bbd0fe91c4 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -770,8 +770,8 @@ test('redirect via server function with middleware does not cause serialization }) test.describe('unhandled exception in middleware (issue #5266)', () => { - // Whitelist the expected 500 error since this test verifies error handling - test.use({ whitelistErrors: ['500'] }) + // Whitelist expected browser console errors since this test verifies error handling. + test.use({ whitelistErrors: ['500', 'Unhandled middleware exception'] }) test('does not crash server and shows error component', async ({ page }) => { // This test verifies that when a middleware throws an unhandled exception, diff --git a/e2e/react-start/start-manifest/tests/start-manifest.spec.ts b/e2e/react-start/start-manifest/tests/start-manifest.spec.ts index 6b592c2a6f..7dfa390997 100644 --- a/e2e/react-start/start-manifest/tests/start-manifest.spec.ts +++ b/e2e/react-start/start-manifest/tests/start-manifest.spec.ts @@ -66,7 +66,7 @@ async function loadBuiltStartManifest() { const manifestModule = await import(moduleUrl) return manifestModule.tsrStartManifest() as { - routes: Record }> + routes: Record }> } } @@ -318,9 +318,9 @@ test('built start manifest preserves shared layout asset identity across sibling const manifest = await loadBuiltStartManifest() - const sharedAAsset = manifest.routes['/shared-a']?.assets?.[0] - const sharedBAsset = manifest.routes['/shared-b']?.assets?.[0] - const sharedCAsset = manifest.routes['/shared-c']?.assets?.[0] + const sharedAAsset = manifest.routes['/shared-a']?.css?.[0] + const sharedBAsset = manifest.routes['/shared-b']?.css?.[0] + const sharedCAsset = manifest.routes['/shared-c']?.css?.[0] expect(sharedAAsset).toBeTruthy() expect(sharedAAsset).toBe(sharedBAsset) diff --git a/e2e/react-start/transform-asset-urls/src/routes/__root.tsx b/e2e/react-start/transform-asset-urls/src/routes/__root.tsx index ff4961a1a4..334bc93088 100644 --- a/e2e/react-start/transform-asset-urls/src/routes/__root.tsx +++ b/e2e/react-start/transform-asset-urls/src/routes/__root.tsx @@ -29,7 +29,7 @@ function RootComponent() { diff --git a/e2e/react-start/transform-asset-urls/src/server.ts b/e2e/react-start/transform-asset-urls/src/server.ts index 5f57a1ef4b..241fc2fe51 100644 --- a/e2e/react-start/transform-asset-urls/src/server.ts +++ b/e2e/react-start/transform-asset-urls/src/server.ts @@ -27,7 +27,7 @@ const createTransformAssetsFn = ({ kind, url }) => { const href = `${cdn}${url}` - if (kind === 'modulepreload') { + if (kind === 'script') { return { href, crossOrigin: 'anonymous', diff --git a/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts b/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts index b006412243..eb02863b3b 100644 --- a/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts +++ b/e2e/react-start/transform-asset-urls/tests/transform-asset-urls.spec.ts @@ -39,18 +39,22 @@ test.describe('transformAssets with CDN prefix', () => { } }) - test('SSR HTML contains CDN-prefixed modulepreload links', async ({ + test('SSR HTML contains CDN-prefixed script preload links', async ({ page, }) => { const html = await getSSRHtml(page) - // All modulepreload links should point to the CDN origin - const modulepreloads = html.match(/rel="modulepreload"[^>]*href="([^"]+)"/g) - expect(modulepreloads).toBeTruthy() - expect(modulepreloads!.length).toBeGreaterThan(0) + // All script preload links should point to the CDN origin + const scriptPreloads = Array.from( + html.matchAll( + /]*\brel="modulepreload")(?=[^>]*\bhref="([^"]+)")[^>]*>/g, + ), + ) + expect(scriptPreloads).toBeTruthy() + expect(scriptPreloads.length).toBeGreaterThan(0) - for (const match of modulepreloads!) { - const href = match.match(/href="([^"]+)"/)?.[1] + for (const match of scriptPreloads) { + const href = match[1] expect(href).toBeTruthy() expect(href).toMatch(/^http:\/\/localhost:\d+\//) } @@ -61,10 +65,10 @@ test.describe('transformAssets with CDN prefix', () => { }) => { const html = await getSSRHtml(page) - const modulepreloadLink = html.match( + const scriptPreloadLink = html.match( /]*rel="modulepreload"[^>]*crossorigin="anonymous"[^>]*>/, ) - expect(modulepreloadLink).toBeTruthy() + expect(scriptPreloadLink).toBeTruthy() const stylesheetLink = html.match( /]*rel="stylesheet"[^>]*crossorigin="use-credentials"[^>]*>/, @@ -134,10 +138,8 @@ test.describe('transformAssets with CDN prefix', () => { }) => { const html = await getSSRHtml(page) - // The client entry script should contain an import() with CDN-prefixed URL - // JSON.stringify produces double quotes; bundler optimisation may use single quotes const clientEntryMatch = html.match( - /import\(["'](http:\/\/localhost:\d+\/[^"']+)["']\)/, + /]+src="(http:\/\/localhost:\d+\/[^"]*\/index[^"]*)"[^>]*>/, ) expect(clientEntryMatch).toBeTruthy() expect(clientEntryMatch![1]).toMatch(/^http:\/\/localhost:\d+\//) diff --git a/e2e/solid-start/csp/tests/csp.spec.ts b/e2e/solid-start/csp/tests/csp.spec.ts index 247f3b5965..f299e73399 100644 --- a/e2e/solid-start/csp/tests/csp.spec.ts +++ b/e2e/solid-start/csp/tests/csp.spec.ts @@ -1,6 +1,21 @@ -import { expect } from '@playwright/test' +import { expect, type Page } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' +async function getRawHtml(page: Page) { + let rawHtml = '' + + await page.route('/', async (route) => { + const response = await route.fetch() + rawHtml = await response.text() + await route.fulfill({ response, body: rawHtml }) + }) + + const response = await page.goto('/') + await page.unrouteAll({ behavior: 'ignoreErrors' }) + + return { rawHtml, response } +} + test('CSP header is set with nonce', async ({ page }) => { const response = await page.goto('/') const csp = response?.headers()['content-security-policy'] @@ -21,9 +36,10 @@ test('Inline styles have nonce attribute', async ({ page }) => { }) test('External script has nonce attribute', async ({ page }) => { - await page.goto('/') - const externalScript = page.locator('script[src="/external.js"]') - await expect(externalScript).toHaveAttribute('nonce') + const { rawHtml } = await getRawHtml(page) + expect(rawHtml).toMatch( + /]*\bsrc="\/external\.js")(?=[^>]*\bnonce="[^"]+")[^>]*>/, + ) }) test('External stylesheet has nonce attribute', async ({ page }) => { @@ -34,15 +50,7 @@ test('External stylesheet has nonce attribute', async ({ page }) => { test('Nonces match between header and elements', async ({ page }) => { // Intercept the HTML response to get raw content before browser strips nonces - let rawHtml = '' - await page.route('/', async (route) => { - const response = await route.fetch() - rawHtml = await response.text() - await route.fulfill({ response }) - }) - - const response = await page.goto('/') - await page.unrouteAll({ behavior: 'ignoreErrors' }) + const { rawHtml, response } = await getRawHtml(page) const csp = response?.headers()['content-security-policy'] || '' diff --git a/e2e/solid-start/start-manifest/tests/start-manifest.spec.ts b/e2e/solid-start/start-manifest/tests/start-manifest.spec.ts index 23244db828..836f62a97f 100644 --- a/e2e/solid-start/start-manifest/tests/start-manifest.spec.ts +++ b/e2e/solid-start/start-manifest/tests/start-manifest.spec.ts @@ -60,7 +60,7 @@ async function loadBuiltStartManifest() { const manifestModule = await import(moduleUrl) return manifestModule.tsrStartManifest() as { - routes: Record }> + routes: Record }> } } @@ -312,9 +312,9 @@ test('built start manifest preserves shared layout asset identity across sibling const manifest = await loadBuiltStartManifest() - const sharedAAsset = manifest.routes['/shared-a']?.assets?.[0] - const sharedBAsset = manifest.routes['/shared-b']?.assets?.[0] - const sharedCAsset = manifest.routes['/shared-c']?.assets?.[0] + const sharedAAsset = manifest.routes['/shared-a']?.css?.[0] + const sharedBAsset = manifest.routes['/shared-b']?.css?.[0] + const sharedCAsset = manifest.routes['/shared-c']?.css?.[0] expect(sharedAAsset).toBeTruthy() expect(sharedAAsset).toBe(sharedBAsset) diff --git a/e2e/vue-start/start-manifest/tests/start-manifest.spec.ts b/e2e/vue-start/start-manifest/tests/start-manifest.spec.ts index 6d13e25764..6c0f0d15ab 100644 --- a/e2e/vue-start/start-manifest/tests/start-manifest.spec.ts +++ b/e2e/vue-start/start-manifest/tests/start-manifest.spec.ts @@ -64,7 +64,7 @@ async function loadBuiltStartManifest() { const manifestModule = await import(moduleUrl) return manifestModule.tsrStartManifest() as { - routes: Record }> + routes: Record }> } } @@ -119,15 +119,21 @@ async function expectDirectEntry({ await expect .poll(async () => { const hrefs = await getHeadStylesheetHrefs(page) - return hasMatchingStylesheetHref(hrefs, expectedStylesheetPattern) + return countMatchingStylesheetHrefs(hrefs, expectedStylesheetPattern) }) - .toBe(true) + .toBe(1) await expect .poll(async () => { const hrefs = await getHeadStylesheetHrefs(page) return countMatchingStylesheetHrefs(hrefs, unexpectedStylesheetPattern) }) .toBe(0) + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return hrefs.length + }) + .toBe(2) await expect .poll(() => getColor('root-shell-marker', page)) @@ -301,6 +307,12 @@ test('home route only renders the root stylesheet and no route-specific CSS', as return countMatchingStylesheetHrefs(hrefs, 'r2-') }) .toBe(0) + await expect + .poll(async () => { + const hrefs = await getHeadStylesheetHrefs(page) + return hrefs.length + }) + .toBe(1) await expect .poll(() => getColor('root-shell-marker', page)) @@ -316,9 +328,9 @@ test('built start manifest preserves shared layout asset identity across sibling const manifest = await loadBuiltStartManifest() - const sharedAAsset = manifest.routes['/shared-a']?.assets?.[0] - const sharedBAsset = manifest.routes['/shared-b']?.assets?.[0] - const sharedCAsset = manifest.routes['/shared-c']?.assets?.[0] + const sharedAAsset = manifest.routes['/shared-a']?.css?.[0] + const sharedBAsset = manifest.routes['/shared-b']?.css?.[0] + const sharedCAsset = manifest.routes['/shared-c']?.css?.[0] expect(sharedAAsset).toBeTruthy() expect(sharedAAsset).toBe(sharedBAsset) diff --git a/packages/react-router/src/Asset.tsx b/packages/react-router/src/Asset.tsx index e3fa1c06de..5e4f159f52 100644 --- a/packages/react-router/src/Asset.tsx +++ b/packages/react-router/src/Asset.tsx @@ -14,10 +14,34 @@ interface ScriptAttrs { suppressHydrationWarning?: boolean } +const noopScriptHandler = () => {} + +function setScriptAttrs( + script: HTMLScriptElement, + attrs: ScriptAttrs | undefined, +) { + if (!attrs) { + return + } + + for (const [key, value] of Object.entries(attrs)) { + if ( + key !== 'suppressHydrationWarning' && + value !== undefined && + value !== false + ) { + script.setAttribute(key, typeof value === 'boolean' ? '' : String(value)) + } + } +} + export function Asset( - asset: RouterManagedTag & { nonce?: string }, + asset: RouterManagedTag & { + nonce?: string + preventScriptHoist?: boolean + }, ): React.ReactElement | null { - const { attrs, children, nonce } = asset + const { attrs, children, nonce, preventScriptHoist } = asset switch (asset.tag) { case 'title': @@ -61,7 +85,11 @@ export function Asset( /> ) case 'script': - return + return ( + + ) default: return null } @@ -106,9 +134,11 @@ function InlineCssStyle({ function Script({ attrs, children, + preventScriptHoist, }: { attrs?: ScriptAttrs children?: string + preventScriptHoist?: boolean }) { const router = useRouter() const hydrated = useHydrated() @@ -141,36 +171,19 @@ function Script({ return attrs.src } })() - const existingScript = Array.from( - document.querySelectorAll('script[src]'), - ).find((el) => (el as HTMLScriptElement).src === normSrc) - - if (existingScript) { - return + for (const el of document.querySelectorAll('script[src]')) { + if ((el as HTMLScriptElement).src === normSrc) { + return + } } const script = document.createElement('script') - for (const [key, value] of Object.entries(attrs)) { - if ( - key !== 'suppressHydrationWarning' && - value !== undefined && - value !== false - ) { - script.setAttribute( - key, - typeof value === 'boolean' ? '' : String(value), - ) - } - } + setScriptAttrs(script, attrs) document.head.appendChild(script) - return () => { - if (script.parentNode) { - script.parentNode.removeChild(script) - } - } + return () => script.remove() } if (typeof children === 'string') { @@ -178,48 +191,29 @@ function Script({ typeof attrs?.type === 'string' ? attrs.type : 'text/javascript' const nonceAttr = typeof attrs?.nonce === 'string' ? attrs.nonce : undefined - const existingScript = Array.from( - document.querySelectorAll('script:not([src])'), - ).find((el) => { - if (!(el instanceof HTMLScriptElement)) return false + for (const el of document.querySelectorAll('script:not([src])')) { + if (!(el instanceof HTMLScriptElement)) { + continue + } + const sType = el.getAttribute('type') ?? 'text/javascript' const sNonce = el.getAttribute('nonce') ?? undefined - return ( + if ( el.textContent === children && sType === typeAttr && sNonce === nonceAttr - ) - }) - - if (existingScript) { - return + ) { + return + } } const script = document.createElement('script') script.textContent = children - - if (attrs) { - for (const [key, value] of Object.entries(attrs)) { - if ( - key !== 'suppressHydrationWarning' && - value !== undefined && - value !== false - ) { - script.setAttribute( - key, - typeof value === 'boolean' ? '' : String(value), - ) - } - } - } + setScriptAttrs(script, attrs) document.head.appendChild(script) - return () => { - if (script.parentNode) { - script.parentNode.removeChild(script) - } - } + return () => script.remove() } return undefined @@ -228,7 +222,20 @@ function Script({ // --- Server rendering --- if (isServer ?? router.isServer) { if (attrs?.src) { - return ` router.serverSsr!.injectHtml(html) }, - dehydrate: async (opts?: { requestAssets?: Array }) => { + dehydrate: async (opts?: { requestAssets?: ManifestRouteAssets }) => { if (_dehydrated) { if (process.env.NODE_ENV !== 'production') { throw new Error('Invariant failed: router is already dehydrated!') @@ -357,61 +480,36 @@ export function attachRouterServerSsrUtils({ const matches = matchesToDehydrate.map(dehydrateMatch) let manifestToDehydrate: Manifest | undefined = undefined - // For currently matched routes, send full manifest (preloads + assets). - // For unmatched routes, include assets only when includeUnmatchedRouteAssets - // is true; otherwise omit them entirely. Preloads for unmatched routes are - // still excluded because they are handled via dynamic imports. + // Only currently matched routes are dehydrated. Other route assets are + // loaded through dynamic imports when those routes become active. if (manifest) { - // Prod-only caching; in dev manifests may be replaced/updated (HMR) - const currentRouteIdsList = matchesToDehydrate.map((m) => m.routeId) - const manifestCacheKey = `${currentRouteIdsList.join('\0')}\0includeUnmatchedRouteAssets=${includeUnmatchedRouteAssets}` - - let filteredRoutes: FilteredRoutes | undefined - - if (isProd) { - filteredRoutes = getManifestCache(manifest).get(manifestCacheKey) - } - - if (!filteredRoutes) { - const currentRouteIds = new Set(currentRouteIdsList) - const nextFilteredRoutes: FilteredRoutes = {} - - for (const routeId in manifest.routes) { - const routeManifest = manifest.routes[routeId]! - if (currentRouteIds.has(routeId)) { - nextFilteredRoutes[routeId] = routeManifest - } else if ( - includeUnmatchedRouteAssets && - routeManifest.assets && - routeManifest.assets.length > 0 - ) { - nextFilteredRoutes[routeId] = { - assets: routeManifest.assets, - } - } - } - - filteredRoutes = stripInlinedStylesheetAssets( - manifest, - nextFilteredRoutes, - matchesToDehydrate, - ) - - if (isProd) { - getManifestCache(manifest).set(manifestCacheKey, filteredRoutes) - } - } + const cacheKey = getMatchedRoutesCacheKey(matchesToDehydrate) + const preparedManifest = getPreparedMatchedManifestRoutes( + manifest, + matchesToDehydrate, + cacheKey, + ) manifestToDehydrate = { - routes: { ...filteredRoutes }, + ...(manifest.scriptFormat + ? { scriptFormat: manifest.scriptFormat } + : {}), + ...(preparedManifest.inlineCssHrefs + ? { inlineStyle: createInlineCssPlaceholderAsset() } + : {}), + routes: preparedManifest.routes, } // Merge request-scoped assets into root route (without mutating cached manifest) - if (opts?.requestAssets?.length) { + const requestAssets = opts?.requestAssets + if (hasRequestAssets(requestAssets)) { const existingRoot = manifestToDehydrate.routes[rootRouteId] - manifestToDehydrate.routes[rootRouteId] = { - ...existingRoot, - assets: [...opts.requestAssets, ...(existingRoot?.assets ?? [])], + manifestToDehydrate.routes = { + ...manifestToDehydrate.routes, + [rootRouteId]: mergeRequestAssetsIntoRootRoute( + existingRoot, + requestAssets, + ), } } } diff --git a/packages/router-core/tests/hydrate.test.ts b/packages/router-core/tests/hydrate.test.ts index d21445ada6..efac230e49 100644 --- a/packages/router-core/tests/hydrate.test.ts +++ b/packages/router-core/tests/hydrate.test.ts @@ -7,6 +7,11 @@ import { dehydrateSsrMatchId } from '../src/ssr/ssr-match-id' import type { LocationRewrite } from '../src' import type { TsrSsrGlobal } from '../src/ssr/types' import type { AnyRouteMatch } from '../src' +import type { Manifest } from '../src/manifest' + +const testManifest: Manifest = { + routes: {}, +} describe('hydrate', () => { let mockWindow: { $_TSR?: TsrSsrGlobal } @@ -93,7 +98,7 @@ describe('hydrate', () => { const mockBuffer = [vi.fn(), vi.fn()] mockWindow.$_TSR = { router: { - manifest: { routes: {} }, + manifest: testManifest, dehydratedData: {}, lastMatchId: '/', matches: [], @@ -122,7 +127,7 @@ describe('hydrate', () => { mockWindow.$_TSR = { router: { - manifest: { routes: {} }, + manifest: testManifest, dehydratedData: {}, lastMatchId: '/', matches: [], @@ -142,7 +147,6 @@ describe('hydrate', () => { }) it('should set manifest in router.ssr', async () => { - const testManifest = { routes: {} } mockWindow.$_TSR = { router: { manifest: testManifest, @@ -198,7 +202,7 @@ describe('hydrate', () => { mockWindow.$_TSR = { router: { - manifest: { routes: {} }, + manifest: testManifest, dehydratedData: {}, lastMatchId: '/', matches: dehydratedMatches, @@ -246,7 +250,7 @@ describe('hydrate', () => { mockWindow.$_TSR = { router: { - manifest: { routes: {} }, + manifest: testManifest, dehydratedData: {}, lastMatchId: '/', matches: dehydratedMatches, @@ -289,7 +293,7 @@ describe('hydrate', () => { mockWindow.$_TSR = { router: { - manifest: { routes: {} }, + manifest: testManifest, dehydratedData: {}, lastMatchId: '/', matches: dehydratedMatches, @@ -333,7 +337,7 @@ describe('hydrate', () => { mockWindow.$_TSR = { router: { - manifest: { routes: {} }, + manifest: testManifest, dehydratedData: {}, lastMatchId: '/', matches: dehydratedMatches, @@ -369,7 +373,7 @@ describe('hydrate', () => { mockWindow.$_TSR = { router: { - manifest: { routes: {} }, + manifest: testManifest, dehydratedData: {}, lastMatchId: dehydrateSsrMatchId('/'), matches: [ @@ -428,7 +432,7 @@ describe('hydrate', () => { mockWindow.$_TSR = { router: { - manifest: { routes: {} }, + manifest: testManifest, dehydratedData: { rewrite: true }, lastMatchId: '/internal/internal', matches: [ @@ -478,7 +482,7 @@ describe('hydrate', () => { mockWindow.$_TSR = { router: { - manifest: { routes: {} }, + manifest: testManifest, dehydratedData: {}, lastMatchId: '/', matches: [ diff --git a/packages/router-core/tests/manifest.test.ts b/packages/router-core/tests/manifest.test.ts new file mode 100644 index 0000000000..f9d5d961df --- /dev/null +++ b/packages/router-core/tests/manifest.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { appendUniqueUserTags } from '../src/manifest' +import type { RouterManagedTag } from '../src/manifest' + +describe('appendUniqueUserTags', () => { + it('does not modify the target for an empty category', () => { + const existing: RouterManagedTag = { + tag: 'meta', + attrs: { + name: 'description', + content: 'existing', + }, + } + const target = [existing] + + appendUniqueUserTags(target, []) + + expect(target).toEqual([existing]) + }) + + it('appends a single tag unchanged', () => { + const tag: RouterManagedTag = { + tag: 'title', + children: 'Home', + } + const target: Array = [] + + appendUniqueUserTags(target, [tag]) + + expect(target).toEqual([tag]) + expect(target[0]).toBe(tag) + }) + + it('dedupes equivalent tags within one appended category', () => { + const firstTag: RouterManagedTag = { + tag: 'link', + attrs: { + href: '/style.css', + rel: 'stylesheet', + }, + } + const duplicateTag: RouterManagedTag = { + tag: 'link', + attrs: { + href: '/style.css', + rel: 'stylesheet', + }, + } + const nextTag: RouterManagedTag = { + tag: 'link', + attrs: { + href: '/next.css', + rel: 'stylesheet', + }, + } + const target: Array = [] + + appendUniqueUserTags(target, [firstTag, duplicateTag, nextTag]) + + expect(target).toEqual([firstTag, nextTag]) + expect(target[0]).toBe(firstTag) + }) + + it('keeps tags that differ by attributes within one category', () => { + const target: Array = [] + + appendUniqueUserTags(target, [ + { + tag: 'meta', + attrs: { + name: 'description', + content: 'first', + }, + }, + { + tag: 'meta', + attrs: { + name: 'description', + content: 'second', + }, + }, + ]) + + expect(target).toEqual([ + { + tag: 'meta', + attrs: { + name: 'description', + content: 'first', + }, + }, + { + tag: 'meta', + attrs: { + name: 'description', + content: 'second', + }, + }, + ]) + }) + + it('dedupes only within the appended tag category', () => { + const tag: RouterManagedTag = { + tag: 'link', + attrs: { + href: '/style.css', + rel: 'stylesheet', + }, + } + const target: Array = [] + + appendUniqueUserTags(target, [tag, tag]) + appendUniqueUserTags(target, [tag]) + + expect(target).toEqual([tag, tag]) + }) +}) diff --git a/packages/router-core/tests/ssr-server-manifest.test.ts b/packages/router-core/tests/ssr-server-manifest.test.ts index b566f13dc8..f76535622f 100644 --- a/packages/router-core/tests/ssr-server-manifest.test.ts +++ b/packages/router-core/tests/ssr-server-manifest.test.ts @@ -5,7 +5,11 @@ import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { GLOBAL_TSR } from '../src/ssr/constants' import { createTestRouter } from './routerTestUtils' import { describe, expect, test } from 'vitest' -import type { Manifest } from '../src/manifest' +import type { + ManifestCssLink, + ManifestRouteAssets, + ServerManifest, +} from '../src/manifest' import type { DehydratedRouter } from '../src/ssr/types' function buildRouter() { @@ -30,35 +34,28 @@ function buildRouter() { }) } -function buildManifest(): Manifest { - const sharedAsset = { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', - }, - } +function buildManifest(): ServerManifest { + const sharedAsset: ManifestCssLink = '/assets/shared.css' return { routes: { __root__: { - assets: [sharedAsset], + css: [sharedAsset], preloads: ['/assets/root.js'], }, '/': { - assets: [sharedAsset], + css: [sharedAsset], preloads: ['/assets/index.js'], }, '/posts': { - assets: [sharedAsset], + css: [sharedAsset], preloads: ['/assets/posts.js'], }, }, } } -function buildInlineManifest(): Manifest { +function buildInlineManifest(): ServerManifest { const manifest = buildManifest() return { ...manifest, @@ -70,16 +67,13 @@ function buildInlineManifest(): Manifest { } } -async function dehydrateManifest(options?: { - includeUnmatchedRouteAssets?: boolean -}) { +async function dehydrateManifest() { const router = buildRouter() const manifest = buildManifest() attachRouterServerSsrUtils({ router, manifest, - includeUnmatchedRouteAssets: options?.includeUnmatchedRouteAssets, }) await router.load() @@ -110,30 +104,192 @@ function parseSerializedRouter(serialized: string): DehydratedRouter { } describe('attachRouterServerSsrUtils manifest dehydration', () => { - test('includes unmatched route assets by default', async () => { + test('omits unmatched route assets by default', async () => { const manifest = await dehydrateManifest() - expect(manifest.routes['/posts']).toEqual({ - assets: [ + expect(manifest.routes['/posts']).toBeUndefined() + expect(manifest.routes['/']?.preloads).toEqual(['/assets/index.js']) + }) + + test('preserves script format when dehydrating the manifest', async () => { + const router = buildRouter() + const manifest: ServerManifest = { + ...buildManifest(), + scriptFormat: 'iife', + } + + attachRouterServerSsrUtils({ + router, + manifest, + }) + + await router.load() + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + const dehydratedManifest = parseSerializedRouter( + script!.children!, + ).manifest! + + expect(dehydratedManifest.scriptFormat).toBe('iife') + }) + + test('maps request-scoped preload links into SSR manifest data', async () => { + const router = buildRouter() + const manifest = buildManifest() + const requestAssets: ManifestRouteAssets = { + preloads: [{ href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }], + scripts: [ + { + attrs: { + src: '/assets/request-script.js', + type: 'module', + }, + children: 'console.log("request")', + }, + ], + css: [{ href: '/assets/rsc-client.css', crossOrigin: 'use-credentials' }], + } + + attachRouterServerSsrUtils({ + router, + manifest, + getRequestAssets: () => requestAssets, + }) + + await router.load() + + expect(router.ssr!.manifest?.routes.__root__).toMatchObject({ + preloads: [ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + '/assets/root.js', + ], + scripts: [ { - tag: 'link', attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', + src: '/assets/request-script.js', + type: 'module', }, + children: 'console.log("request")', }, ], + css: [ + { href: '/assets/rsc-client.css', crossOrigin: 'use-credentials' }, + '/assets/shared.css', + ], }) }) - test('omits unmatched route assets when disabled', async () => { - const manifest = await dehydrateManifest({ - includeUnmatchedRouteAssets: false, + test('maps preloads-only request assets into SSR manifest data', async () => { + const router = buildRouter() + const manifest = buildManifest() + const requestAssets: ManifestRouteAssets = { + preloads: [{ href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }], + } + + attachRouterServerSsrUtils({ + router, + manifest, + getRequestAssets: () => requestAssets, }) - expect(manifest.routes['/posts']).toBeUndefined() - expect(manifest.routes['/']?.preloads).toEqual(['/assets/index.js']) + await router.load() + + expect(router.ssr!.manifest?.routes.__root__?.preloads).toEqual([ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + '/assets/root.js', + ]) + }) + + test('dehydrates request-scoped preload links into manifest data', async () => { + const router = buildRouter() + const manifest = buildManifest() + + attachRouterServerSsrUtils({ + router, + manifest, + }) + + await router.load() + await router.serverSsr!.dehydrate({ + requestAssets: { + preloads: [{ href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }], + scripts: [ + { + attrs: { + src: '/assets/request-script.js', + type: 'module', + }, + children: 'console.log("request")', + }, + ], + css: [ + { + href: '/assets/rsc-client.css', + crossOrigin: 'use-credentials', + }, + ], + }, + }) + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + const dehydratedManifest = parseSerializedRouter( + script!.children!, + ).manifest! + + expect(dehydratedManifest.routes.__root__).toMatchObject({ + preloads: [ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + '/assets/root.js', + ], + scripts: [ + { + attrs: { + src: '/assets/request-script.js', + type: 'module', + }, + children: 'console.log("request")', + }, + ], + css: [ + { href: '/assets/rsc-client.css', crossOrigin: 'use-credentials' }, + '/assets/shared.css', + ], + }) + expect(Array.isArray(dehydratedManifest.routes.__root__?.css)).toBe(true) + expect((dehydratedManifest.routes.__root__?.css as any).links).toBe( + undefined, + ) + }) + + test('dehydrates preloads-only request assets into manifest data', async () => { + const router = buildRouter() + const manifest = buildManifest() + + attachRouterServerSsrUtils({ + router, + manifest, + }) + + await router.load() + await router.serverSsr!.dehydrate({ + requestAssets: { + preloads: [{ href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }], + }, + }) + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + const dehydratedManifest = parseSerializedRouter( + script!.children!, + ).manifest! + + expect(dehydratedManifest.routes.__root__?.preloads).toEqual([ + { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, + '/assets/root.js', + ]) }) test('inlines stylesheet assets for SSR and strips stylesheet links from dehydration', async () => { @@ -143,20 +299,14 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { attachRouterServerSsrUtils({ router, manifest, - includeUnmatchedRouteAssets: false, }) await router.load() - const ssrRootAssets = router.ssr!.manifest?.routes.__root__?.assets ?? [] - expect( - ssrRootAssets.find( - (asset) => - asset.tag === 'style' && - asset.inlineCss && - asset.children === '.shared{color:red}', - ), - ).toBeTruthy() + const ssrInlineCss = router.ssr!.manifest?.inlineStyle + expect(ssrInlineCss).toMatchObject({ + children: '.shared{color:red}', + }) await router.serverSsr!.dehydrate() @@ -164,28 +314,91 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { expect(script?.children).toBeTruthy() const dehydratedRouter = parseSerializedRouter(script!.children!) const dehydratedManifest = dehydratedRouter.manifest! - const rootAssets = dehydratedManifest.routes.__root__?.assets ?? [] - const allAssets = Object.values(dehydratedManifest.routes).flatMap( - (route) => route.assets ?? [], + const rootInlineCss = dehydratedManifest.inlineStyle + const allLinks = Object.values(dehydratedManifest.routes).flatMap( + (route) => route.css ?? [], ) - expect(rootAssets).toEqual([ - { - tag: 'style', - attrs: { - suppressHydrationWarning: true, - }, - inlineCss: true, + expect(rootInlineCss).toEqual({ + attrs: { + suppressHydrationWarning: true, }, - ]) + }) + expect('inlineCss' in dehydratedManifest).toBe(false) expect( - allAssets.some( - (asset) => - asset.tag === 'link' && asset.attrs?.href === '/assets/shared.css', + allLinks.some((asset) => + typeof asset === 'string' + ? asset === '/assets/shared.css' + : asset.href === '/assets/shared.css', ), ).toBe(false) expect(dehydratedManifest.routes['/']?.preloads).toEqual([ '/assets/index.js', ]) }) + + test('strips only inlinable stylesheet links from dehydrated manifest data', async () => { + const router = buildRouter() + const manifest: ServerManifest = { + inlineCss: { + styles: { + '/assets/root-inline.css': '.root{color:red}', + '/assets/index-inline.css': '.index{color:blue}', + }, + }, + routes: { + __root__: { + css: [ + '/assets/root-inline.css', + { + href: '/assets/root-linked.css', + crossOrigin: 'anonymous', + }, + ], + }, + '/': { + css: [ + { + href: '/assets/index-inline.css', + crossOrigin: 'use-credentials', + }, + '/assets/index-linked.css', + ], + preloads: ['/assets/index.js'], + }, + }, + } + + attachRouterServerSsrUtils({ + router, + manifest, + }) + + await router.load() + + expect(router.ssr!.manifest?.inlineStyle).toMatchObject({ + children: '.root{color:red}.index{color:blue}', + }) + + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + const dehydratedManifest = parseSerializedRouter( + script!.children!, + ).manifest! + + expect(dehydratedManifest.routes.__root__?.css).toEqual([ + { + href: '/assets/root-linked.css', + crossOrigin: 'anonymous', + }, + ]) + expect(dehydratedManifest.routes['/']?.css).toEqual([ + '/assets/index-linked.css', + ]) + expect(dehydratedManifest.routes['/']?.preloads).toEqual([ + '/assets/index.js', + ]) + }) }) diff --git a/packages/solid-router/src/HeadContent.dev.tsx b/packages/solid-router/src/HeadContent.dev.tsx index 83c241d3fb..adf46ab800 100644 --- a/packages/solid-router/src/HeadContent.dev.tsx +++ b/packages/solid-router/src/HeadContent.dev.tsx @@ -1,12 +1,11 @@ import { MetaProvider } from '@solidjs/meta' import { For, createEffect, createMemo } from 'solid-js' +import { DEV_STYLES_ATTR } from '@tanstack/router-core' import { Asset } from './Asset' import { useHydrated } from './ClientOnly' import { useTags } from './headContentUtils' import type { HeadContentProps } from './HeadContent' -const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles' - /** * @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route. * When using full document hydration (hydrating from ``), this component should be rendered in the `` @@ -33,7 +32,9 @@ export function HeadContent(props: HeadContentProps) { // Filter out dev styles after hydration const filteredTags = createMemo(() => { if (hydrated()) { - return tags().filter((tag) => !tag.attrs?.[DEV_STYLES_ATTR]) + return tags().filter( + (tag) => tag.tag !== 'link' || tag.attrs?.[DEV_STYLES_ATTR] !== true, + ) } return tags() }) diff --git a/packages/solid-router/src/Scripts.tsx b/packages/solid-router/src/Scripts.tsx index 188bd5e187..4c1ece99c6 100644 --- a/packages/solid-router/src/Scripts.tsx +++ b/packages/solid-router/src/Scripts.tsx @@ -1,13 +1,14 @@ import * as Solid from 'solid-js' +import { isServer } from '@tanstack/router-core/isServer' import { Asset } from './Asset' import { useRouter } from './useRouter' -import type { RouterManagedTag } from '@tanstack/router-core' +import type { AnyRouteMatch, RouterManagedTag } from '@tanstack/router-core' export const Scripts = () => { const router = useRouter() const nonce = router.options.ssr?.nonce - const activeMatches = Solid.createMemo(() => router.stores.matches.get()) - const assetScripts = Solid.createMemo(() => { + + const getAssetScripts = (matches: Array) => { const assetScripts: Array = [] const manifest = router.ssr?.manifest @@ -15,57 +16,67 @@ export const Scripts = () => { return [] } - activeMatches() - .map((match) => router.looseRoutesById[match.routeId]!) - .forEach((route) => - manifest.routes[route.id]?.assets - ?.filter((d) => d.tag === 'script') - .forEach((asset) => { - assetScripts.push({ - tag: 'script', - attrs: { ...asset.attrs, nonce }, - children: asset.children, - } as any) - }), - ) + for (const match of matches) { + const scripts = manifest.routes[match.routeId]?.scripts + + if (!scripts) { + continue + } + + for (const asset of scripts) { + assetScripts.push({ + tag: 'script', + attrs: { ...asset.attrs, nonce }, + children: asset.children, + }) + } + } return assetScripts - }) + } - const scripts = Solid.createMemo(() => + const getScripts = (matches: Array): Array => ( - activeMatches() + matches .map((match) => match.scripts!) .flat(1) .filter(Boolean) as Array - ).map(({ children, ...script }) => ({ - tag: 'script', - attrs: { - ...script, - nonce, - }, - children, - })), - ) + ).map( + ({ children, ...script }) => + ({ + tag: 'script', + attrs: { + ...script, + nonce, + }, + children, + }) satisfies RouterManagedTag, + ) - let serverBufferedScript: RouterManagedTag | undefined = undefined + const activeMatches = Solid.createMemo(() => router.stores.matches.get()) + const assetScripts = Solid.createMemo(() => getAssetScripts(activeMatches())) + const scripts = Solid.createMemo(() => getScripts(activeMatches())) - if (router.serverSsr) { - serverBufferedScript = router.serverSsr.takeBufferedScripts() - } + return renderScripts(router, scripts(), assetScripts()) +} - const allScripts = [ - ...scripts(), - ...assetScripts(), - ] as Array +function renderScripts( + router: ReturnType, + scripts: Array, + assetScripts: Array, +) { + const allScripts = [...scripts, ...assetScripts] as Array - if (serverBufferedScript) { - allScripts.unshift(serverBufferedScript) + if ((isServer ?? router.isServer) && router.serverSsr) { + const serverBufferedScript = router.serverSsr.takeBufferedScripts() + if (serverBufferedScript) { + allScripts.unshift(serverBufferedScript) + } } return ( <> - {allScripts.map((asset, i) => ( + {allScripts.map((asset) => ( ))} diff --git a/packages/solid-router/src/headContentUtils.tsx b/packages/solid-router/src/headContentUtils.tsx index da77c86191..b4ca3e8a4e 100644 --- a/packages/solid-router/src/headContentUtils.tsx +++ b/packages/solid-router/src/headContentUtils.tsx @@ -1,9 +1,10 @@ import * as Solid from 'solid-js' import { + appendUniqueUserTags, escapeHtml, getAssetCrossOrigin, - isInlinableStylesheet, - resolveManifestAssetLink, + getScriptPreloadAttrs, + resolveManifestCssLink, } from '@tanstack/router-core' import { useRouter } from './useRouter' import type { @@ -21,8 +22,8 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const activeMatches = Solid.createMemo(() => router.stores.matches.get()) const routeMeta = Solid.createMemo(() => activeMatches() - .map((match) => match.meta!) - .filter(Boolean), + .map((match) => match.meta) + .filter((meta) => meta !== undefined), ) const meta: Solid.Accessor> = Solid.createMemo(() => { @@ -100,9 +101,8 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const links = Solid.createMemo(() => { const matches = activeMatches() const constructed = matches - .map((match) => match.links!) - .filter(Boolean) - .flat(1) + .flatMap((match) => match.links ?? []) + .filter((link) => link !== undefined) .map((link) => ({ tag: 'link', attrs: { @@ -111,125 +111,111 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { }, })) satisfies Array - const manifest = router.ssr?.manifest + return constructed + }) - const assets = matches - .map((match) => manifest?.routes[match.routeId]?.assets ?? []) - .filter(Boolean) - .flat(1) - .flatMap((asset): Array => { - if (asset.tag === 'link') { - if (isInlinableStylesheet(manifest, asset)) { - return [] - } + const manifestCssTags = Solid.createMemo(() => { + const manifest = router.ssr?.manifest + const tags: Array = [] - return [ - { - tag: 'link', - attrs: { - ...asset.attrs, - crossOrigin: - getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? - asset.attrs?.crossOrigin, - nonce, - }, - }, - ] - } + if (!manifest) { + return tags + } - if (asset.tag === 'style') { - return [ - { - tag: 'style', - attrs: { - ...asset.attrs, - nonce, - }, - children: asset.children, - ...(asset.inlineCss ? { inlineCss: true as const } : {}), - }, - ] - } + for (const match of activeMatches()) { + manifest.routes[match.routeId]?.css?.forEach((link) => { + const resolvedLink = resolveManifestCssLink(link) + tags.push({ + tag: 'link', + attrs: { + rel: 'stylesheet', + ...resolvedLink, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? + resolvedLink.crossOrigin, + nonce, + }, + }) + }) + } - return [] + if (manifest.inlineStyle) { + tags.push({ + tag: 'style', + attrs: { + ...manifest.inlineStyle.attrs, + nonce, + }, + children: manifest.inlineStyle.children, + inlineCss: true, }) + } - return [...constructed, ...assets] + return tags }) const preloadLinks = Solid.createMemo(() => { const matches = activeMatches() const preloadLinks: Array = [] - matches - .map((match) => router.looseRoutesById[match.routeId]!) - .forEach((route) => - router.ssr?.manifest?.routes[route.id]?.preloads - ?.filter(Boolean) - .forEach((preload) => { - const preloadLink = resolveManifestAssetLink(preload) - preloadLinks.push({ - tag: 'link', - attrs: { - rel: 'modulepreload', - href: preloadLink.href, - crossOrigin: - getAssetCrossOrigin(assetCrossOrigin, 'modulepreload') ?? - preloadLink.crossOrigin, - nonce, - }, - }) - }), - ) + matches.forEach((match) => + router.ssr?.manifest?.routes[match.routeId]?.preloads + ?.filter(Boolean) + .forEach((preload) => { + preloadLinks.push({ + tag: 'link', + attrs: { + ...getScriptPreloadAttrs( + router.ssr?.manifest, + preload, + assetCrossOrigin, + ), + nonce, + }, + }) + }), + ) return preloadLinks }) - const styles = Solid.createMemo(() => - ( - activeMatches() - .map((match) => match.styles!) - .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...style }) => ({ - tag: 'style', - attrs: { - ...style, - nonce, - }, - children, - })), - ) + const styles = Solid.createMemo(() => { + return activeMatches() + .flatMap((match) => match.styles ?? []) + .filter((style) => style !== undefined) + .map(({ children, ...style }) => ({ + tag: 'style', + attrs: { + ...style, + nonce, + }, + children: children as string | undefined, + })) satisfies Array + }) - const headScripts = Solid.createMemo(() => - ( - activeMatches() - .map((match) => match.headScripts!) - .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...script }) => ({ - tag: 'script', - attrs: { - ...script, - nonce, - }, - children, - })), - ) + const headScripts = Solid.createMemo(() => { + return activeMatches() + .flatMap((match) => match.headScripts ?? []) + .filter((script) => script !== undefined) + .map(({ children, ...script }) => ({ + tag: 'script', + attrs: { + ...script, + nonce, + }, + children: children as string | undefined, + })) satisfies Array + }) return Solid.createMemo((prev: Array | undefined) => { - const next = uniqBy( - [ - ...meta(), - ...preloadLinks(), - ...links(), - ...styles(), - ...headScripts(), - ] as Array, - (d) => { - return JSON.stringify(d) - }, - ) + const next: Array = [] + appendUniqueUserTags(next, meta()) + next.push(...preloadLinks()) + appendUniqueUserTags(next, links()) + next.push(...manifestCssTags()) + appendUniqueUserTags(next, styles()) + appendUniqueUserTags(next, headScripts()) + if (prev === undefined) { return next } @@ -262,15 +248,3 @@ function replaceEqualTags( return isEqual ? prev : result } - -export function uniqBy(arr: Array, fn: (item: T) => string) { - const seen = new Set() - return arr.filter((item) => { - const key = fn(item) - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) -} diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index 29f3138352..b15faaef19 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -21,20 +21,16 @@ import { import { Scripts } from '../src/Scripts' import type { Manifest } from '@tanstack/router-core' -const createTestManifest = (routeId: string) => +const createTestManifest = ( + routeId: string, + options?: { scriptFormat?: Manifest['scriptFormat'] }, +) => ({ + ...(options?.scriptFormat ? { scriptFormat: options.scriptFormat } : {}), routes: { [routeId]: { preloads: ['/main.js'], - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/main.css', - }, - }, - ], + css: ['/main.css'], }, }, }) satisfies Manifest @@ -259,23 +255,13 @@ describe('ssr scripts', () => { routes: { [rootRoute.id]: { preloads: ['/root.js'], - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/main.css', - }, - }, - ], + css: ['/main.css'], }, [aRoute.id]: { preloads: ['/a.js'], - assets: [], }, [bRoute.id]: { preloads: ['/b.js', '/b-child.js'], - assets: [], }, }, }, @@ -314,7 +300,7 @@ describe('ssr scripts', () => { ).toHaveLength(1) }) - test('applies assetCrossOrigin to manifest assets and preloads', async () => { + test('applies assetCrossOrigin to manifest stylesheets and preloads', async () => { const history = createTestBrowserHistory() const rootRoute = createRootRoute({ @@ -323,7 +309,7 @@ describe('ssr scripts', () => { <> @@ -370,6 +356,100 @@ describe('ssr scripts', () => { ?.getAttribute('crossorigin'), ).toBe('anonymous') }) + + test('renders runtime manifest inlineStyle', async () => { + const history = createTestBrowserHistory() + + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: { + inlineStyle: { + attrs: { id: 'runtime-inline-style' }, + children: '.runtime{color:red}', + }, + routes: { + [rootRoute.id]: {}, + }, + }, + } + + await router.load() + + render(() => ) + + await waitFor(() => { + expect( + document.head.querySelector('style#runtime-inline-style'), + ).toBeTruthy() + }) + + expect( + document.head.querySelector('style#runtime-inline-style')?.textContent, + ).toBe('.runtime{color:red}') + }) + + test('renders preload as script links for iife manifest preloads', async () => { + const history = createTestBrowserHistory() + + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: createTestManifest(rootRoute.id, { scriptFormat: 'iife' }), + } + + await router.load() + + render(() => ) + + await waitFor(() => { + expect( + document.head.querySelector('link[rel="preload"][as="script"]'), + ).toBeTruthy() + }) + + expect(document.head.querySelector('link[rel="modulepreload"]')).toBeFalsy() + }) }) describe('ssr HeadContent', () => { diff --git a/packages/start-plugin-core/src/import-protection/virtualModules.ts b/packages/start-plugin-core/src/import-protection/virtualModules.ts index 948f06fddb..29b6878927 100644 --- a/packages/start-plugin-core/src/import-protection/virtualModules.ts +++ b/packages/start-plugin-core/src/import-protection/virtualModules.ts @@ -104,21 +104,19 @@ function __report(action, accessPath) { ` : '' - const diagGetTraps = hasDiag - ? ` + const primitiveGetTraps = ` if (prop === Symbol.toPrimitive) { return () => { - __report('toPrimitive', name); + ${hasDiag ? `__report('toPrimitive', name);` : ''} return '[import-protection mock]'; }; } if (prop === 'toString' || prop === 'valueOf' || prop === 'toJSON') { return () => { - __report(String(prop), name); + ${hasDiag ? `__report(String(prop), name);` : ''} return '[import-protection mock]'; }; }` - : '' const applyBody = hasDiag ? `__report('call', name + '()'); @@ -151,7 +149,7 @@ function ${fnName}(name) { if (prop === 'caller') return null; if (prop === 'then') return (f) => Promise.resolve(f(proxy)); if (prop === 'catch') return () => Promise.resolve(proxy); - if (prop === 'finally') return (f) => { f(); return Promise.resolve(proxy); };${diagGetTraps} + if (prop === 'finally') return (f) => { f(); return Promise.resolve(proxy); };${primitiveGetTraps} if (typeof prop === 'symbol') return undefined; if (!(prop in children)) { children[prop] = ${fnName}(name + '.' + prop); diff --git a/packages/start-plugin-core/src/rsbuild/COMPILER_ARCHITECTURE.md b/packages/start-plugin-core/src/rsbuild/COMPILER_ARCHITECTURE.md new file mode 100644 index 0000000000..a97c500b0d --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/COMPILER_ARCHITECTURE.md @@ -0,0 +1,199 @@ +# Rsbuild Compiler Architecture + +This document explains the TanStack Start compiler integration for Rsbuild and Rspack. It focuses on server-function discovery, Rspack cache interaction, and the resolver virtual module. + +## Goals + +The Rsbuild integration has to do three things at the same time: + +- Compile Start-specific source patterns such as server functions, isomorphic functions, middleware, and compiler virtual modules. +- Keep server-function metadata available when Rspack restores modules from its persistent cache and skips transforms. +- Generate the `#tanstack-start-server-fn-resolver` virtual module after the server-function registry is complete for the current compilation. + +The important point is that server-function discovery is a side effect of compilation. If Rspack restores a transformed module from persistent cache, the transform side effect does not run. We therefore store the discovered metadata on the Rspack module itself and replay it from `module.buildInfo`. + +## Main Files + +- `plugin.ts` wires the Rsbuild plugin phases, virtual modules, RSC hooks, compiler ordering, and resolver rebuild hooks. +- `start-compiler-host.ts` registers the Start compiler transforms, captures server-function metadata, writes metadata to Rspack `buildInfo`, and replays cached metadata. +- `start-compiler-metadata.ts` defines the string keys and types used by the metadata loader. +- `start-compiler-metadata-loader.ts` is the real Rspack loader that writes server-function metadata to the current module's `buildInfo`. +- `virtual-modules.ts` owns the resolver virtual module and writes updated virtual module content per Rsbuild environment. +- `../start-compiler/handleCreateServerFn.ts` discovers server functions while compiling caller modules. +- `../start-compiler/server-fn-resolver-module.ts` generates the resolver module from the current server-function registry. + +## Environments + +Start registers compiler work for each Rsbuild environment that can affect server-function metadata: + +- `client` discovers functions referenced from browser code and marks them client-accessible. +- `ssr` discovers functions reachable from the server-rendered route graph. +- A separate provider environment is included when server-function providers are not compiled in `ssr` and RSC is not enabled. + +The shared registry is `serverFnsById`. It is passed to `virtual-modules.ts`, which uses it to generate resolver module content. + +## Cold Transform Path + +`registerStartCompilerTransforms()` registers an `api.transform({ order: 'pre' })` transform for each Start compiler environment. + +When a matching module is compiled normally: + +1. Rsbuild runs the Start transform in the pre-loader phase. +2. The transform checks compiler virtual modules first. +3. It runs cheap code filters to skip files that cannot contain Start compiler patterns. +4. It lazily creates one `StartCompiler` per environment. +5. It detects which compiler features are present in the current code. +6. It serializes access to the per-environment `StartCompiler` through `runCompilerTask()`. +7. It calls `compiler.compile({ id, code, detectedKinds })`. +8. During compile, `handleCreateServerFn()` reports discovered server functions through `onServerFnsById`. +9. The transform stores the discovered server functions for this module in the environment's pending metadata map. + +The module ID used for transform metadata is `ctx.resource`. The metadata loader later reads `this.resource`. Using the same Rspack resource string keeps the handoff stable across resource queries. + +## Server-Function Discovery + +Server functions are discovered in caller modules, not in provider modules. In `handleCreateServerFn()`, each discovered function records: + +- `functionName`, the generated handler export name. +- `functionId`, the stable server-function ID. +- `filename`, the caller module ID. +- `extractedFilename`, the provider module ID with the server-function split query. +- `isClientReferenced`, whether client-origin calls are allowed. + +`onServerFnsById` merges discoveries into the shared `serverFnsById` registry. While a compile task is active, it also merges the same discoveries into that module's `activeServerFnMetadata` object. After compile finishes, that object becomes the metadata payload for the module. + +Per-environment compiler tasks are serialized because `StartCompiler` owns mutable module caches and because `activeServerFnMetadata` must only describe the module currently being compiled in that environment. + +## Loading And Resolving Modules + +The compiler sometimes needs to read or resolve imported modules while compiling a source file. + +`start-compiler-host.ts` uses two native Rsbuild/Rspack surfaces for this: + +- `ctx.resolve()` from the active Rsbuild transform context resolves module IDs. +- `compiler.inputFileSystem` reads source through Rspack's input filesystem. + +An `AsyncLocalStorage` value binds `loadModule` and `resolveId` back to the active transform context. That lets the compiler add dependencies to the current Rspack module without reaching into Rspack private resolver internals. + +## Why A Real Loader Is Used + +Rsbuild `api.transform()` is implemented as a loader, but its callback only exposes the transform context. It does not expose the current `NormalModule` or `module.buildInfo`. + +Rspack persists custom `module.buildInfo` fields. To write metadata there, Start installs a small real loader after the transform: + +1. `start-compiler-host.ts` adds a rule with `enforce: 'post'` for Start transformable modules. +2. The rule points to the emitted `start-compiler-metadata-loader.js` file. +3. The loader receives the environment's pending metadata map through loader options. +4. A public `NormalModule.getCompilationHooks(compilation).loader` hook adds a setter to the loader context. +5. The loader reads metadata for `this.resource` and calls the setter. +6. The setter writes to `module.buildInfo['tanstack.start.serverFns']`. + +Only string-keyed, JSON-serializable `buildInfo` fields are used. Rspack persists those fields with the module cache. Symbol keys and private cache internals are intentionally avoided. + +## Cache-Enabled And Cache-Disabled Behavior + +The metadata loader is installed regardless of `performance.buildCache`. + +When persistent cache is enabled, Rspack may later restore a module without rerunning the Start transform or the metadata loader. The `buildInfo` payload is then replayed from the cached module. + +When persistent cache is disabled, the same loader path still runs during normal compilation and writes in-memory `buildInfo`. Keeping one path avoids conditional behavior and keeps cold, watch, cache-disabled, and cache-enabled builds aligned. The overhead is one small post loader for modules that already match the Start transform test. + +The cache-specific part is not the loader itself. The cache-specific part is that Rspack can persist and later restore the `buildInfo` field. + +## Stale Metadata Clearing + +If a module previously contained server functions and then no longer produces metadata, the metadata loader writes an empty payload: + +```ts +{ version: 1, serverFnsById: {} } +``` + +This prevents stale server-function entries from surviving on a cached module's `buildInfo` after a file edit removes or renames server functions. + +## Warm Cache Replay + +Each relevant compiler installs a `finishMake` hook at stage `-20`. + +At that point Rspack has built or restored the module graph for the environment, and module `buildInfo` is available. The hook: + +1. Iterates over `compilation.modules`. +2. Reads `module.buildInfo['tanstack.start.serverFns']`. +3. Validates the payload with the versioned schema. +4. Merges valid module metadata into an environment snapshot. +5. Stores the snapshot in `serverFnsByEnvironment`. +6. Rebuilds the shared `serverFnsById` registry from all environment snapshots. +7. Notifies virtual modules that resolver content may have changed. + +The shared registry is rebuilt from snapshots instead of being append-only. This is what lets deleted or renamed server functions disappear after watch rebuilds or warm-cache restores. + +The pending transform-to-loader metadata maps are cleared on each Rspack `compile` hook. That prevents metadata discovered in an earlier compilation from being written to a later module when the transform no longer runs or no longer discovers server functions. + +## Why Snapshots Are Per Environment + +Each Rsbuild environment can see different information for the same file. For example, client and SSR caller environments can mark functions as client-accessible, while server and provider environments own server execution details. The merged registry preserves the broadest known access information. + +A single global replay pass would either drop another environment's metadata or keep stale metadata too long. The model is therefore: + +- Build one current snapshot per environment. +- Replace the shared registry with the union of all available snapshots. +- Regenerate resolver content from that shared registry. + +This keeps the global registry convergent while allowing Rspack environments to finish independently. + +## Resolver Rebuild Ordering + +The resolver virtual module must be rebuilt after cached metadata has been replayed. + +The ordering is: + +1. `finishMake` stage `-20`: replay `buildInfo` into environment snapshots and rebuild `serverFnsById`. +2. `finishMake` stage `-10`: write resolver virtual module content and rebuild modules that import the resolver. + +For non-RSC builds, each server-like environment that needs the resolver installs the stage `-10` rebuild hook. + +For RSC builds, the server environment installs the RSC resolver rebuild hook at stage `-10`. It generates provider-style resolver content because RSC server actions run inside the server/RSC environment and do not need the client-reference check in that resolver layer. + +## Compiler Ordering + +Non-RSC Rsbuild uses Rspack `MultiCompiler.setDependencies()` to make resolver-owning environments wait for metadata-producing environments. + +The intended order is: + +- `client` runs before every server-like environment. +- If a separate provider environment exists, the provider runs after `client`. +- If a separate provider environment exists, `ssr` runs after both `client` and the provider. + +That ordering prevents the `ssr` resolver module from being finalized before provider metadata is available. + +RSC builds intentionally do not add these dependencies. Rspack's native RSC coordinator interleaves server and client compilation phases. Adding MultiCompiler dependencies on top of it can deadlock the build. + +## Native APIs Used + +The implementation uses Rsbuild/Rspack integration points instead of cache internals: + +- Rsbuild `api.transform()` for source transforms. +- Rsbuild transform `ctx.resolve()` for module resolution. +- Rspack `compiler.inputFileSystem` for source reads. +- Rspack loaders for access to the loader pipeline. +- Rspack `NormalModule.getCompilationHooks(compilation).loader` to extend loader context. +- Rspack `module.buildInfo` for module-scoped persisted metadata. +- Rspack `compiler.hooks.finishMake` for module graph replay and resolver rebuild ordering. +- Rspack `compilation.rebuildModule()` through the local `rebuildModulesContaining()` helper. +- Rspack `MultiCompiler.setDependencies()` for non-RSC compiler ordering. +- Rspack `experiments.VirtualModulesPlugin` for virtual module content. + +The implementation avoids private persistent-cache files, direct cache mutation, direct `_module` access from loaders, global handoff state, and disabling module caching with `this.cacheable(false)`. + +## When A New Server Function Is Compiled + +For a new server function in a normal cold compile: + +1. Rsbuild sends the source module through the Start pre transform. +2. `StartCompiler.compile()` rewrites the caller module and discovers the server function. +3. `onServerFnsById` merges the function into `serverFnsById` and the active module metadata object. +4. The transform stores `{ version: 1, serverFnsById: discoveredServerFnsById }` in the environment metadata map under the module resource. +5. The post metadata loader runs for the same resource. +6. The loader writes that payload into `module.buildInfo['tanstack.start.serverFns']`. +7. Rspack owns the module from there. If persistent cache is enabled, Rspack persists the module and its string-keyed `buildInfo` with its normal cache machinery. +8. At `finishMake -20`, Start reads current module `buildInfo` and rebuilds the environment snapshot. +9. At `finishMake -10`, Start rewrites and rebuilds the resolver virtual module so runtime lookups can find the new function. diff --git a/packages/start-plugin-core/src/rsbuild/planning.ts b/packages/start-plugin-core/src/rsbuild/planning.ts index 5ab3b631f2..856ea23240 100644 --- a/packages/start-plugin-core/src/rsbuild/planning.ts +++ b/packages/start-plugin-core/src/rsbuild/planning.ts @@ -5,6 +5,7 @@ import { ENTRY_POINTS } from '../constants' import type { EnvironmentConfig } from '@rsbuild/core' import type { ResolvedStartEntryPlan } from '../planning' import type { RsbuildEnvironmentOverrides } from './types' +import type { ScriptFormat } from '@tanstack/router-core' const require = createRequire(import.meta.url) @@ -72,6 +73,7 @@ export function createRsbuildEnvironmentPlan(opts: { publicBase: string serverFnProviderEnv: string environmentOverrides?: RsbuildEnvironmentOverrides + scriptFormat?: ScriptFormat rsc?: boolean | undefined dev?: boolean | undefined }): RsbuildEnvironmentPlanResult { @@ -87,6 +89,20 @@ export function createRsbuildEnvironmentPlan(opts: { : {}), } const environmentOverrides = opts.environmentOverrides ?? {} + const scriptFormat = opts.scriptFormat ?? 'module' + const clientOutputModule = scriptFormat === 'module' + const userClientOutputModule = + environmentOverrides.client?.output?.module ?? + environmentOverrides.all?.output?.module + + if ( + typeof userClientOutputModule === 'boolean' && + userClientOutputModule !== clientOutputModule + ) { + throw new Error( + 'TanStack Start rsbuild.client.output controls environments.client.output.module. Remove environments.client.output.module or set rsbuild.client.output to match it.', + ) + } return { environments: { @@ -102,7 +118,7 @@ export function createRsbuildEnvironmentPlan(opts: { }, output: { target: 'web', - module: true, + module: clientOutputModule, distPath: { root: opts.clientOutputDirectory, }, @@ -224,7 +240,7 @@ export function resolveRsbuildOutputDirectory(opts: { } function normalizeEntryPath(path: string) { - return path.replaceAll('\\', '/') + return path.includes('\\') ? path.replaceAll('\\', '/') : path } function resolveFromRoot(specifier: string, root: string): string { diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 242cb549ae..8243d2e28b 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -1,7 +1,6 @@ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { hasKeys } from '@tanstack/router-core' import { joinURL } from 'ufo' import { applyResolvedBaseAndOutput, @@ -10,7 +9,7 @@ import { } from '../config-context' import { normalizePath } from '../utils' import { createServerFnBasePath, normalizePublicBase } from '../planning' -import { parseStartConfig } from './schema' +import { parseStartConfig, rsbuildClientOutputSchema } from './schema' import { RSBUILD_ENVIRONMENT_NAMES, RSBUILD_RSC_LAYERS, @@ -40,6 +39,7 @@ import type { rspack as rspackNamespaceType, } from '@rsbuild/core' import type { TanStackStartRsbuildInputConfig } from './schema' +import type { ScriptFormat } from '@tanstack/router-core' // Detect whether this plugin source is running from inside the TanStack // Router monorepo (packages/start-plugin-core/src/rsbuild/plugin.ts). When @@ -62,6 +62,7 @@ type RscPluginPair = ReturnType< type RspackConfig = Parameters[0] type RspackCompiler = Rspack.Compiler type RspackCompilationExtended = Rspack.Compilation +type RspackAsset = ReturnType[number] export function tanStackStartRsbuild( corePluginOpts: TanStackStartRsbuildPluginCoreOptions, @@ -78,6 +79,9 @@ export function tanStackStartRsbuild( const { getConfig, resolvedStartConfig } = configContext const serverFnProviderEnv = corePluginOpts.providerEnvironmentName const ssrIsProvider = corePluginOpts.ssrIsProvider + const scriptFormat = rsbuildClientOutputSchema.parse( + startPluginOpts.rsbuild?.client?.output ?? 'module', + ) satisfies ScriptFormat // RSC plugin instances — created lazily when rspack namespace is available let rscPlugins: RscPluginPair | undefined @@ -90,6 +94,18 @@ export function tanStackStartRsbuild( return { name: 'tanstack-start-rsbuild', setup(api: RsbuildPluginAPI) { + const startCompilerEnvironments = [ + { name: RSBUILD_ENVIRONMENT_NAMES.client, type: 'client' as const }, + { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' as const }, + ...(serverFnProviderEnv !== RSBUILD_ENVIRONMENT_NAMES.server && + !rscEnabled + ? [{ name: serverFnProviderEnv, type: 'server' as const }] + : []), + ] + const startCompilerServerEnvironmentNames = startCompilerEnvironments + .filter((env) => env.type === 'server') + .map((env) => env.name) + // --------------------------------------------------------------- // 1. modifyRsbuildConfig — resolve config, set up environments // --------------------------------------------------------------- @@ -155,6 +171,7 @@ export function tanStackStartRsbuild( publicBase: resolvedStartConfig.basePaths.publicBase, serverFnProviderEnv, environmentOverrides: corePluginOpts.rsbuild?.environments, + scriptFormat, rsc: rscOpts, dev: isDev, }) @@ -252,6 +269,7 @@ export function tanStackStartRsbuild( // so defer this read until transform time instead of falling back to // process.cwd() during plugin setup. root: () => resolvedStartConfig.root || process.cwd(), + environments: startCompilerEnvironments, providerEnvName: serverFnProviderEnv, generateFunctionId: startPluginOpts.serverFns?.generateFunctionId, compilerTransforms: corePluginOpts.compilerTransforms, @@ -266,14 +284,7 @@ export function tanStackStartRsbuild( registerImportProtection(api, { getConfig, framework: corePluginOpts.framework, - environments: [ - { name: RSBUILD_ENVIRONMENT_NAMES.client, type: 'client' }, - { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' }, - ...(serverFnProviderEnv !== RSBUILD_ENVIRONMENT_NAMES.server && - !rscEnabled - ? [{ name: serverFnProviderEnv, type: 'server' as const }] - : []), - ], + environments: startCompilerEnvironments, }) // --------------------------------------------------------------- @@ -290,9 +301,41 @@ export function tanStackStartRsbuild( getDevClientEntryUrl: (publicBase: string) => joinURL(publicBase, 'static/js/index.js'), rscEnabled, + scriptFormat, }) updateServerFnResolver = virtualModuleState.updateServerFnResolver + if (!rscEnabled) { + api.modifyRspackConfig((config, utils) => { + if ( + !startCompilerServerEnvironmentNames.includes( + utils.environment.name, + ) + ) { + return + } + + config.plugins.push({ + apply(compiler: RspackCompiler) { + compiler.hooks.finishMake.tapPromise( + { + name: 'TanStackStartServerFnResolverRebuild', + stage: -10, + }, + async (compilation: RspackCompilationExtended) => { + virtualModuleState.updateServerFnResolver() + + await rebuildModulesContaining( + compilation, + virtualModuleState.serverFnResolverPath, + ) + }, + ) + }, + }) + }) + } + // --------------------------------------------------------------- // 4. Client build stats capture via processAssets // --------------------------------------------------------------- @@ -484,10 +527,6 @@ export function tanStackStartRsbuild( stage: -10, }, async (compilation: RspackCompilationExtended) => { - if (!hasKeys(serverFnsById)) { - return - } - const resolverContent = virtualModuleState.generateCurrentResolverContent(true) virtualModuleState.tryUpdateServerFnResolver( @@ -579,13 +618,23 @@ export function tanStackStartRsbuild( api.onAfterCreateCompiler(({ compiler }) => { // MultiCompiler has a `compilers` array; single compiler does not if ('compilers' in compiler) { - const serverCompiler = compiler.compilers.find( - (c) => c.name === RSBUILD_ENVIRONMENT_NAMES.server, - ) - if (serverCompiler) { - compiler.setDependencies(serverCompiler, [ - RSBUILD_ENVIRONMENT_NAMES.client, - ]) + for (const environmentName of startCompilerServerEnvironmentNames) { + const serverCompiler = compiler.compilers.find( + (c) => c.name === environmentName, + ) + if (serverCompiler) { + const dependencies: Array = [ + RSBUILD_ENVIRONMENT_NAMES.client, + ] + if ( + environmentName === RSBUILD_ENVIRONMENT_NAMES.server && + serverFnProviderEnv !== RSBUILD_ENVIRONMENT_NAMES.server + ) { + dependencies.push(serverFnProviderEnv) + } + + compiler.setDependencies(serverCompiler, dependencies) + } } } }) @@ -620,18 +669,32 @@ export function tanStackStartRsbuild( .PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, }, () => { - const assetsWithPlaceholder = compilation - .getAssets() - .flatMap((asset) => { - if (!asset.name.endsWith('.js')) return [] - - const sourceStr = String(asset.source.source()) - return sourceStr.includes(manifestPlaceholderLiteral) - ? [{ asset, sourceStr }] - : [] + let assetsWithPlaceholder: + | Array<{ asset: RspackAsset; sourceStr: string }> + | undefined + + for (const asset of compilation.getAssets()) { + if (!asset.name.endsWith('.js')) { + continue + } + + const sourceStr = String(asset.source.source()) + if (!sourceStr.includes(manifestPlaceholderLiteral)) { + continue + } + + if (!assetsWithPlaceholder) { + assetsWithPlaceholder = [] + } + assetsWithPlaceholder.push({ + asset, + sourceStr, }) + } - if (assetsWithPlaceholder.length === 0) return + if (!assetsWithPlaceholder) { + return + } const clientBuild = getClientBuild() if (!clientBuild) { @@ -720,25 +783,25 @@ function rebuildModulesContaining( compilation: RspackCompilationExtended, identifierFragment: string, ): Promise { - const modulesToRebuild = Array.from(compilation.modules).filter((mod) => - mod.identifier().includes(identifierFragment), - ) + const rebuilds: Array> = [] + for (const mod of compilation.modules) { + if (!mod.identifier().includes(identifierFragment)) { + continue + } - if (modulesToRebuild.length === 0) { - return Promise.resolve() + rebuilds.push( + new Promise((resolve, reject) => { + compilation.rebuildModule(mod, (err: Error | null) => { + if (err) reject(err) + else resolve() + }) + }), + ) } - return Promise.all( - modulesToRebuild.map( - (mod) => - new Promise((resolve, reject) => { - compilation.rebuildModule(mod, (err: Error | null) => { - if (err) reject(err) - else resolve() - }) - }), - ), - ).then(() => undefined) + return rebuilds.length === 0 + ? Promise.resolve() + : Promise.all(rebuilds).then(() => undefined) } /** diff --git a/packages/start-plugin-core/src/rsbuild/schema.ts b/packages/start-plugin-core/src/rsbuild/schema.ts index adbc5c5328..86194e309c 100644 --- a/packages/start-plugin-core/src/rsbuild/schema.ts +++ b/packages/start-plugin-core/src/rsbuild/schema.ts @@ -6,11 +6,21 @@ import { import type { CompileStartFrameworkOptions } from '../types' import type { InlineCssInputOptions } from '../schema' +export const rsbuildClientOutputSchema = z.enum(['module', 'iife']) + export const tanstackStartRsbuildOptionsSchema = tanstackStartOptionsObjectSchema .extend({ rsbuild: z - .object({ installDevServerMiddleware: z.boolean().optional() }) + .object({ + installDevServerMiddleware: z.boolean().optional(), + client: z + .object({ + output: rsbuildClientOutputSchema.optional().default('module'), + }) + .optional() + .prefault({}), + }) .optional(), }) .optional() @@ -30,6 +40,12 @@ export function parseStartConfig( export type TanStackStartRsbuildInputConfig = z.input< typeof tanstackStartRsbuildOptionsSchema > & { + rsbuild?: { + installDevServerMiddleware?: boolean + client?: { + output?: z.input + } + } server?: { build?: { inlineCss?: InlineCssInputOptions diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts index d0ef77e34b..5cf9319238 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts @@ -1,5 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' -import { pathToFileURL } from 'node:url' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { z } from 'zod' import { TRANSFORM_ID_REGEX } from '../constants' import { detectKindsInCode } from '../start-compiler/compiler' import { getTransformCodeFilterForEnv } from '../start-compiler/config' @@ -11,8 +13,15 @@ import { } from '../start-compiler/host' import { cleanId } from '../start-compiler/utils' import { createHydrateCompilerPlugin } from '../hydrate-when-transform' -import { RSBUILD_ENVIRONMENT_NAMES } from './planning' +import { + SERVER_FN_BUILD_INFO_CONTEXT_KEY, + SERVER_FN_BUILD_INFO_FIELD, +} from './start-compiler-metadata' import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' +import type { + ServerFnBuildInfo, + ServerFnBuildInfoLoaderContext, +} from './start-compiler-metadata' import type { CompileStartFrameworkOptions, StartCompilerImportTransform, @@ -29,18 +38,74 @@ type RsbuildTransformContext = Parameters< Parameters[1] >[0] type RsbuildInputFileSystem = NonNullable +type ServerFnMetadataById = Map +type StartCompilerEnvironment = { + name: string + type: 'client' | 'server' +} + +const serverFnSchema = z.object({ + functionName: z.string(), + functionId: z.string(), + extractedFilename: z.string(), + filename: z.string(), + isClientReferenced: z.boolean().optional(), +}) + +const serverFnBuildInfoSchema = z.object({ + version: z.literal(1), + serverFnsById: z.record(z.string(), serverFnSchema), +}) /** - * Rsbuild dev server fn ref when: uses file:// URLs for absolute paths. + * In Rsbuild dev, use file:// URLs for absolute server function paths. * These are directly importable by Node's ESM VM runner without any bundler * path conventions (unlike Vite's /@id/ prefix). */ const rsbuildDevServerFnModuleSpecifierEncoder: DevServerFnModuleSpecifierEncoder = ({ extractedFilename }) => pathToFileURL(extractedFilename).href +const currentDir = dirname(fileURLToPath(import.meta.url)) +const metadataLoaderFilename = 'start-compiler-metadata-loader.js' +const EMPTY_SERVER_FN_BUILD_INFO: ServerFnBuildInfo = { + version: 1, + serverFnsById: {}, +} + +function resolveMetadataLoader(): string { + return resolve(currentDir, metadataLoaderFilename) +} + +function readServerFnBuildInfo( + module: Rspack.Module, +): Record | null { + const result = serverFnBuildInfoSchema.safeParse( + module.buildInfo[SERVER_FN_BUILD_INFO_FIELD], + ) + if (!result.success) { + return null + } + + return result.data.serverFnsById +} + +function setServerFnBuildInfoLoaderContext( + loaderContext: Rspack.LoaderContext & ServerFnBuildInfoLoaderContext, + module: Rspack.Module, +) { + loaderContext[SERVER_FN_BUILD_INFO_CONTEXT_KEY] = (metadata) => { + if (metadata) { + module.buildInfo[SERVER_FN_BUILD_INFO_FIELD] = metadata + } else if (module.buildInfo[SERVER_FN_BUILD_INFO_FIELD]) { + module.buildInfo[SERVER_FN_BUILD_INFO_FIELD] = EMPTY_SERVER_FN_BUILD_INFO + } + } +} + export interface StartCompilerHostOptions { framework: CompileStartFrameworkOptions root: string | (() => string) + environments: Array providerEnvName: string generateFunctionId?: GenerateFunctionIdFnOptional compilerTransforms?: Array | undefined @@ -63,15 +128,56 @@ export function registerStartCompilerTransforms( serverFnsById: Record } { const compilers = new Map>() + // Serialize access to each StartCompiler's mutable per-environment caches. + const compilerQueues = new Map>() const inputFileSystems = new Map() const transformContextStorage = new AsyncLocalStorage() + const serverFnMetadataByEnvironment = new Map() + const serverFnsByEnvironment = new Map>() const serverFnsById = opts.serverFnsById ?? {} const getRoot = () => typeof opts.root === 'function' ? opts.root() : opts.root + const getServerFnMetadata = (environmentName: string) => { + let metadataById = serverFnMetadataByEnvironment.get(environmentName) + + if (!metadataById) { + metadataById = new Map() + serverFnMetadataByEnvironment.set(environmentName, metadataById) + } + + return metadataById + } + const runCompilerTask = async ( + environmentName: string, + task: () => Promise, + ): Promise => { + const previous = compilerQueues.get(environmentName) ?? Promise.resolve() + const next = previous.catch(() => undefined).then(task) + + compilerQueues.set( + environmentName, + next.then( + () => undefined, + () => undefined, + ), + ) + + return next + } - const onServerFnsById = (d: Record) => { - mergeServerFnsById(serverFnsById, d) + const replaceServerFnsByIdFromEnvironmentSnapshots = () => { + const nextServerFnsById: Record = {} + + for (const snapshot of serverFnsByEnvironment.values()) { + mergeServerFnsById(nextServerFnsById, snapshot) + } + + for (const key of Object.keys(serverFnsById)) { + delete serverFnsById[key] + } + + Object.assign(serverFnsById, nextServerFnsById) opts.onServerFnsByIdChange?.() } const compilerPlugins = [ @@ -81,36 +187,55 @@ export function registerStartCompilerTransforms( const isDev = api.context.action === 'dev' const mode = isDev ? 'dev' : 'build' + const metadataLoader = resolveMetadataLoader() - const environments: Array<{ - name: string - type: 'client' | 'server' - }> = [ - { name: RSBUILD_ENVIRONMENT_NAMES.client, type: 'client' }, - { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' }, - ] + const environments = opts.environments - // Pre-compute code filter patterns per environment type - const codeFilters: Record<'client' | 'server', Array> = { - client: getTransformCodeFilterForEnv('client', { - compilerPlugins, - }), - server: getTransformCodeFilterForEnv('server', { - compilerTransforms: opts.compilerTransforms, - compilerPlugins, - }), - } + // Rspack persistent cache restores modules without re-running api.transform. + // Keep server function modules cacheable by writing discovered metadata into + // buildInfo from a real loader, then replaying it from cached modules. + api.modifyRspackConfig((config, utils) => { + if (!environments.some((env) => env.name === utils.environment.name)) { + return + } + + const rules = config.module.rules ?? [] + rules.push({ + test: TRANSFORM_ID_REGEX[0], + enforce: 'post', + use: [ + { + loader: metadataLoader, + options: { + metadataById: getServerFnMetadata(utils.environment.name), + }, + }, + ], + }) + config.module.rules = rules + }) for (const env of environments) { - const envCodeFilters = codeFilters[env.type] const compilerTransforms = - env.name === RSBUILD_ENVIRONMENT_NAMES.server - ? opts.compilerTransforms - : undefined + env.name === opts.providerEnvName ? opts.compilerTransforms : undefined + const envCodeFilters = getTransformCodeFilterForEnv(env.type, { + compilerTransforms, + compilerPlugins, + }) const serverFnProviderModuleDirectives = env.name === opts.providerEnvName ? opts.serverFnProviderModuleDirectives : undefined + let activeServerFnMetadata: Record | undefined + const onServerFnsById = (d: Record) => { + mergeServerFnsById(serverFnsById, d) + + if (activeServerFnMetadata) { + mergeServerFnsById(activeServerFnMetadata, d) + } + + opts.onServerFnsByIdChange?.() + } api.transform( { @@ -126,7 +251,7 @@ export function registerStartCompilerTransforms( code: string map: StartCompilerTransformResult['map'] } | null = null - const id = ctx.resourcePath + (ctx.resourceQuery || '') + const id = ctx.resource const root = getRoot() const virtualResult = loadCompilerVirtualModule(compilerPlugins, { @@ -232,10 +357,24 @@ export function registerStartCompilerTransforms( const detectedKinds = detectKindsInCode(nextCode, env.type, { compilerTransforms, }) - const result = await compiler.compile({ - id, - code: nextCode, - detectedKinds, + const discoveredServerFnsById: Record = {} + const result = await runCompilerTask(env.name, async () => { + activeServerFnMetadata = discoveredServerFnsById + + try { + return await compiler.compile({ + id, + code: nextCode, + detectedKinds, + }) + } finally { + activeServerFnMetadata = undefined + } + }) + + getServerFnMetadata(env.name).set(id, { + version: 1, + serverFnsById: discoveredServerFnsById, }) if (result) { @@ -252,12 +391,59 @@ export function registerStartCompilerTransforms( } api.modifyRspackConfig((config, utils) => { + if (!environments.some((env) => env.name === utils.environment.name)) { + return + } + config.plugins.push({ apply(compiler: Rspack.Compiler) { if (compiler.inputFileSystem) { inputFileSystems.set(utils.environment.name, compiler.inputFileSystem) } + compiler.hooks.compilation.tap( + 'TanStackStartCompilerMetadataLoaderContext', + (compilation) => { + utils.rspack.NormalModule.getCompilationHooks( + compilation, + ).loader.tap( + 'TanStackStartCompilerMetadataLoaderContext', + (loaderContext, module) => { + setServerFnBuildInfoLoaderContext(loaderContext, module) + }, + ) + }, + ) + + compiler.hooks.compile.tap('TanStackStartCompilerMetadataCleanup', () => + getServerFnMetadata(utils.environment.name).clear(), + ) + + compiler.hooks.finishMake.tap( + { + name: 'TanStackStartCompilerCachedServerFnMetadata', + stage: -20, + }, + (compilation) => { + const restoredServerFnsById: Record = {} + + for (const module of compilation.modules) { + const metadata = readServerFnBuildInfo(module) + if (!metadata) { + continue + } + + mergeServerFnsById(restoredServerFnsById, metadata) + } + + serverFnsByEnvironment.set( + utils.environment.name, + restoredServerFnsById, + ) + replaceServerFnsByIdFromEnvironmentSnapshots() + }, + ) + compiler.hooks.watchRun.tap( 'TanStackStartCompilerModuleInvalidation', (watchCompiler) => { diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-metadata-loader.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata-loader.ts new file mode 100644 index 0000000000..89a22d82cd --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata-loader.ts @@ -0,0 +1,22 @@ +import { SERVER_FN_BUILD_INFO_CONTEXT_KEY } from './start-compiler-metadata' +import type { Rspack } from '@rsbuild/core' +import type { + ServerFnBuildInfoLoaderContext, + ServerFnMetadataLoaderOptions, +} from './start-compiler-metadata' + +const tanStackStartCompilerMetadataLoader: Rspack.LoaderDefinition< + ServerFnMetadataLoaderOptions, + ServerFnBuildInfoLoaderContext +> = function (source, map): void { + const { metadataById } = this.getOptions() + const id = this.resource + const metadata = metadataById.get(id) + const setBuildInfo = this[SERVER_FN_BUILD_INFO_CONTEXT_KEY] + + setBuildInfo?.(metadata ?? null) + + this.callback(null, source, map) +} + +export default tanStackStartCompilerMetadataLoader diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts new file mode 100644 index 0000000000..dfa6119246 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-metadata.ts @@ -0,0 +1,20 @@ +import type { ServerFn } from '../start-compiler/types' + +export const SERVER_FN_BUILD_INFO_FIELD = 'tanstack.start.serverFns' +export const SERVER_FN_BUILD_INFO_CONTEXT_KEY = + 'tanstack.start.setServerFnBuildInfo' + +export type ServerFnBuildInfo = { + version: 1 + serverFnsById: Record +} + +export type SetServerFnBuildInfo = (metadata: ServerFnBuildInfo | null) => void + +export type ServerFnBuildInfoLoaderContext = { + [SERVER_FN_BUILD_INFO_CONTEXT_KEY]?: SetServerFnBuildInfo +} + +export type ServerFnMetadataLoaderOptions = { + metadataById: Map +} diff --git a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts index 766141d487..056a3e9bfc 100644 --- a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts @@ -14,6 +14,7 @@ import type { } from '../types' import type { InlineCssOptions } from '../start-manifest-plugin/manifestBuilder' import type { ServerFn } from '../start-compiler/types' +import type { ScriptFormat } from '@tanstack/router-core' type RspackNamespace = typeof rspackNamespaceType type RspackVirtualModulesPlugin = InstanceType< @@ -66,9 +67,17 @@ export interface VirtualModuleState { // Manifest module codegen // --------------------------------------------------------------------------- -function generateManifestModuleDev(devClientEntryUrl: string): string { +function getScriptFormatProperty(scriptFormat: ScriptFormat): string { + return scriptFormat === 'iife' ? ` scriptFormat: 'iife',\n` : '' +} + +function generateManifestModuleDev( + devClientEntryUrl: string, + scriptFormat: ScriptFormat, +): string { + const scriptFormatProperty = getScriptFormatProperty(scriptFormat) return `const fallbackManifest = { - routes: {}, +${scriptFormatProperty} routes: {}, clientEntry: '${devClientEntryUrl}', } export const tsrStartManifest = () => globalThis[${JSON.stringify(DEV_START_MANIFEST_GLOBAL)}] ?? fallbackManifest` @@ -78,6 +87,7 @@ function buildStartManifestData( clientBuild: NormalizedClientBuild, publicBase: string, inlineCss: InlineCssOptions, + scriptFormat: ScriptFormat, ) { const routeTreeRoutes = globalThis.TSS_ROUTES_MANIFEST return buildStartManifest({ @@ -85,6 +95,7 @@ function buildStartManifestData( routeTreeRoutes, basePath: publicBase, inlineCss, + scriptFormat, }) } @@ -92,9 +103,10 @@ function serializeStartManifestData( clientBuild: NormalizedClientBuild, publicBase: string, inlineCss: InlineCssOptions, + scriptFormat: ScriptFormat, ): string { return JSON.stringify( - buildStartManifestData(clientBuild, publicBase, inlineCss), + buildStartManifestData(clientBuild, publicBase, inlineCss, scriptFormat), ) } @@ -103,13 +115,14 @@ function generateManifestModuleBuild( publicBase: string, _devClientEntryUrl: string, inlineCss: InlineCssOptions, + scriptFormat: ScriptFormat, ): string { if (!clientBuild) { return `const tsrStartManifestData = ${JSON.stringify(START_MANIFEST_PLACEHOLDER)} export const tsrStartManifest = () => tsrStartManifestData` } - return `export const tsrStartManifest = () => (${serializeStartManifestData(clientBuild, publicBase, inlineCss)})` + return `export const tsrStartManifest = () => (${serializeStartManifestData(clientBuild, publicBase, inlineCss, scriptFormat)})` } // --------------------------------------------------------------------------- @@ -220,6 +233,7 @@ export interface RegisterVirtualModulesOptions { getDevClientEntryUrl: (publicBase: string) => string /** Whether RSC virtual modules should be registered. */ rscEnabled?: boolean | undefined + scriptFormat: ScriptFormat } /** @@ -363,12 +377,13 @@ export function registerVirtualModules( resolvedStartConfig.basePaths.publicBase, ) content[paths.manifest] = isDev - ? generateManifestModuleDev(devClientEntryUrl) + ? generateManifestModuleDev(devClientEntryUrl, opts.scriptFormat) : generateManifestModuleBuild( clientBuild, resolvedStartConfig.basePaths.publicBase, devClientEntryUrl, startConfig.server.build.inlineCss, + opts.scriptFormat, ) } else { content[paths.manifest] = 'export default {}' @@ -514,6 +529,7 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is !isDev ? startConfig.server.build.inlineCss : { enabled: false, transformAssets: false }, + opts.scriptFormat, ) }, @@ -527,6 +543,7 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is !isDev ? startConfig.server.build.inlineCss : { enabled: false, transformAssets: false }, + opts.scriptFormat, ) }, @@ -543,17 +560,15 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is clientBuild, resolvedStartConfig.basePaths.publicBase, { enabled: false, transformAssets: false }, + opts.scriptFormat, ) } }, updateServerFnResolver() { - for (const environmentName of new Set([ - RSBUILD_ENVIRONMENT_NAMES.server, - ...(hasSeparateProviderEnvironment ? [opts.providerEnvName] : []), - ])) { + const updateEnvironment = (environmentName: string) => { if (!needsServerFnResolver(environmentName)) { - continue + return } writeResolverContent( @@ -561,6 +576,11 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is generateResolverContent(environmentName), ) } + + updateEnvironment(RSBUILD_ENVIRONMENT_NAMES.server) + if (hasSeparateProviderEnvironment) { + updateEnvironment(opts.providerEnvName) + } }, tryUpdateServerFnResolver(content: string) { diff --git a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts index 6f27f37d4d..a07237fe03 100644 --- a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts +++ b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts @@ -4,6 +4,7 @@ import { joinURL } from 'ufo' import { getStylesheetHref, resolveManifestAssetLink, + resolveManifestCssLink, rootRouteId, } from '@tanstack/router-core' import { @@ -12,7 +13,12 @@ import { normalizeViteClientChunk, } from '../vite/start-manifest-plugin/normalized-client-build' import { processInlineCssUrls } from './inlineCss' -import type { ManifestAssetLink, RouterManagedTag } from '@tanstack/router-core' +import type { + ManifestAssetLink, + ManifestCssLink, + ManifestScript, + ScriptFormat, +} from '@tanstack/router-core' import type { InlineCssTemplate } from './inlineCss' import type { NormalizedClientBuild, NormalizedClientChunk } from '../types' @@ -21,12 +27,15 @@ const VISITING_CHUNK = 1 type RouteTreeRoute = { filePath?: string preloads?: Array - assets?: Array + scripts?: Array + css?: Array children?: Array } type RouteTreeRoutes = Record +type AdditionalRouteManifestEntry = ManifestCssLink | ManifestScript + interface ScannedClientChunks { entryChunk: NormalizedClientChunk chunksByFileName: ReadonlyMap @@ -36,16 +45,18 @@ interface ScannedClientChunks { interface ManifestAssetResolvers { getAssetPath: (fileName: string) => string getChunkPreloads: (chunk: NormalizedClientChunk) => Array - getStylesheetAsset: (cssFile: string) => RouterManagedTag + getStylesheetLink: (cssFile: string) => ManifestCssLink } type DedupeRoute = { preloads?: Array - assets?: Array + scripts?: Array + css?: Array children?: Array } export interface StartManifest { + scriptFormat?: ScriptFormat routes: Record clientEntry: string inlineCss?: { @@ -92,12 +103,10 @@ export function appendUniqueStrings( return result ?? target } -export function appendUniqueAssets( - target: Array | undefined, - source: Array, +function appendUniqueStylesheets( + target: Array | undefined, + source: Array, ) { - // Same semantics as appendUniqueStrings, but uniqueness is based on the - // serialized asset identity instead of object reference. if (source.length === 0) { return target } @@ -106,11 +115,11 @@ export function appendUniqueAssets( return source } - const seen = new Set(target.map(getAssetIdentity)) - let result: Array | undefined + const seen = new Set(target.map(getStylesheetIdentity)) + let result: Array | undefined - for (const asset of source) { - const identity = getAssetIdentity(asset) + for (const stylesheet of source) { + const identity = getStylesheetIdentity(stylesheet) if (seen.has(identity)) { continue } @@ -119,21 +128,25 @@ export function appendUniqueAssets( if (!result) { result = target.slice() } - result.push(asset) + result.push(stylesheet) } return result ?? target } -function getAssetIdentity(asset: RouterManagedTag) { +function getStylesheetIdentity(attrs: ManifestCssLink) { + const resolved = resolveManifestCssLink(attrs) + return `${resolved.href}\0${resolved.crossOrigin ?? ''}` +} + +function getScriptIdentity(script: ManifestScript) { return JSON.stringify({ - tag: asset.tag, - attrs: normalizeAssetAttrs(asset.attrs), - children: 'children' in asset ? (asset.children ?? null) : null, + attrs: normalizeAttrs(script.attrs), + children: script.children ?? null, }) } -function normalizeAssetAttrs(attrs: Record | undefined) { +function normalizeAttrs(attrs: Record | undefined) { if (!attrs) { return null } @@ -150,26 +163,63 @@ function normalizeAssetAttrs(attrs: Record | undefined) { function mergeRouteChunkData(options: { route: RouteTreeRoute chunk: NormalizedClientChunk - getChunkCssAssets: (chunk: NormalizedClientChunk) => Array + getChunkCssAssets: (chunk: NormalizedClientChunk) => Array getChunkPreloads: (chunk: NormalizedClientChunk) => Array }) { - const chunkAssets = options.getChunkCssAssets(options.chunk) + const stylesheets = options.getChunkCssAssets(options.chunk) const chunkPreloads = options.getChunkPreloads(options.chunk) - options.route.assets = appendUniqueAssets(options.route.assets, chunkAssets) + appendRouteStylesheets(options.route, stylesheets) options.route.preloads = appendUniqueStrings( options.route.preloads, chunkPreloads, ) } +function appendRouteStylesheets( + route: RouteTreeRoute, + stylesheets: Array, +) { + if (stylesheets.length === 0) { + return + } + + route.css = appendUniqueStylesheets(route.css, stylesheets) +} + +function appendAdditionalRouteEntries( + route: RouteTreeRoute, + entries: ReadonlyArray, +) { + if (entries.length === 0) { + return + } + + const stylesheets: Array = [] + const scripts: Array = [] + + for (const entry of entries) { + if (typeof entry === 'string' || 'href' in entry) { + stylesheets.push(entry) + } else { + scripts.push(entry) + } + } + + appendRouteStylesheets(route, stylesheets) + if (scripts.length > 0) { + route.scripts = [...(route.scripts ?? []), ...scripts] + } +} + export function buildStartManifest(options: { clientBuild: NormalizedClientBuild routeTreeRoutes: RouteTreeRoutes basePath: string inlineCss?: InlineCssOptions + scriptFormat?: ScriptFormat additionalRouteAssets?: Partial< - Record> + Record> > }): StartManifest { const scannedChunks = scanClientChunks(options.clientBuild) @@ -186,12 +236,13 @@ export function buildStartManifest(options: { dedupeNestedRouteManifestEntries(rootRouteId, routes[rootRouteId]!, routes) - // Prune routes with no assets or preloads from the manifest - for (const routeId of Object.keys(routes)) { + // Prune routes with no manifest data + for (const routeId in routes) { const route = routes[routeId]! - const hasAssets = route.assets && route.assets.length > 0 + const hasScripts = route.scripts && route.scripts.length > 0 + const hasCssLinks = route.css && route.css.length > 0 const hasPreloads = route.preloads && route.preloads.length > 0 - if (!hasAssets && !hasPreloads) { + if (!hasScripts && !hasCssLinks && !hasPreloads) { delete routes[routeId] } } @@ -201,6 +252,10 @@ export function buildStartManifest(options: { clientEntry: assetResolvers.getAssetPath(scannedChunks.entryChunk.fileName), } + if (options.scriptFormat === 'iife') { + result.scriptFormat = 'iife' + } + if (options.inlineCss?.enabled) { result.inlineCss = buildInlineCssManifestData({ routes, @@ -254,7 +309,7 @@ export function createManifestAssetResolvers( basePath: string, ): ManifestAssetResolvers { const assetPathByFileName = new Map() - const stylesheetAssetByFileName = new Map() + const stylesheetLinkByFileName = new Map() const preloadsByChunk = new Map>() const getAssetPath = (fileName: string) => { @@ -268,24 +323,17 @@ export function createManifestAssetResolvers( return assetPath } - const getStylesheetAsset = (cssFile: string) => { - const cachedAsset = stylesheetAssetByFileName.get(cssFile) - if (cachedAsset) { - return cachedAsset + const getStylesheetLink = (cssFile: string) => { + const cachedLink = stylesheetLinkByFileName.get(cssFile) + if (cachedLink) { + return cachedLink } const href = getAssetPath(cssFile) - const asset = { - tag: 'link', - attrs: { - rel: 'stylesheet', - href, - type: 'text/css', - }, - } satisfies RouterManagedTag + const link = href satisfies ManifestCssLink - stylesheetAssetByFileName.set(cssFile, asset) - return asset + stylesheetLinkByFileName.set(cssFile, link) + return link } const getChunkPreloads = (chunk: NormalizedClientChunk) => { @@ -307,39 +355,36 @@ export function createManifestAssetResolvers( return { getAssetPath, getChunkPreloads, - getStylesheetAsset, + getStylesheetLink, } } export function createChunkCssAssetCollector(options: { chunksByFileName: ReadonlyMap - getStylesheetAsset: (cssFile: string) => RouterManagedTag + getStylesheetLink: (cssFile: string) => ManifestCssLink }) { - const assetsByChunk = new Map< - NormalizedClientChunk, - Array - >() + const linksByChunk = new Map>() const stateByChunk = new Map() const appendAsset = ( - assets: Array, - seenAssets: Set, - asset: RouterManagedTag, + links: Array, + seenLinks: Set, + link: ManifestCssLink, ) => { - if (seenAssets.has(asset)) { + if (seenLinks.has(link)) { return } - seenAssets.add(asset) - assets.push(asset) + seenLinks.add(link) + links.push(link) } const getChunkCssAssets = ( chunk: NormalizedClientChunk, - ): Array => { - const cachedAssets = assetsByChunk.get(chunk) - if (cachedAssets) { - return cachedAssets + ): Array => { + const cachedLinks = linksByChunk.get(chunk) + if (cachedLinks) { + return cachedLinks } if (stateByChunk.get(chunk) === VISITING_CHUNK) { @@ -347,8 +392,8 @@ export function createChunkCssAssetCollector(options: { } stateByChunk.set(chunk, VISITING_CHUNK) - const assets: Array = [] - const seenAssets = new Set() + const links: Array = [] + const seenLinks = new Set() for (let i = 0; i < chunk.imports.length; i++) { const importedChunk = options.chunksByFileName.get(chunk.imports[i]!) @@ -356,19 +401,19 @@ export function createChunkCssAssetCollector(options: { continue } - const importedAssets = getChunkCssAssets(importedChunk) - for (let j = 0; j < importedAssets.length; j++) { - appendAsset(assets, seenAssets, importedAssets[j]!) + const importedLinks = getChunkCssAssets(importedChunk) + for (let j = 0; j < importedLinks.length; j++) { + appendAsset(links, seenLinks, importedLinks[j]!) } } for (const cssFile of chunk.css) { - appendAsset(assets, seenAssets, options.getStylesheetAsset(cssFile)) + appendAsset(links, seenLinks, options.getStylesheetLink(cssFile)) } stateByChunk.delete(chunk) - assetsByChunk.set(chunk, assets) - return assets + linksByChunk.set(chunk, links) + return links } return { getChunkCssAssets } @@ -383,11 +428,8 @@ function buildInlineCssManifestData(options: { const stylesheetHrefs = new Set() for (const route of Object.values(options.routes)) { - for (const asset of route.assets ?? []) { - const href = getStylesheetHref(asset) - if (href) { - stylesheetHrefs.add(href) - } + for (const link of route.css ?? []) { + stylesheetHrefs.add(getStylesheetHref(link)) } } @@ -403,7 +445,7 @@ function buildInlineCssManifestData(options: { const { getAssetPath } = createManifestAssetResolvers(options.basePath) const styles: Record = {} - const templates: Record = {} + let templates: Record | undefined const missingHrefs = new Set(stylesheetHrefs) for (const [cssFile, css] of options.cssContentByFileName) { @@ -420,6 +462,7 @@ function buildInlineCssManifestData(options: { styles[cssHref] = result.css if (result.template) { + templates ||= {} templates[cssHref] = result.template } missingHrefs.delete(cssHref) @@ -435,7 +478,7 @@ function buildInlineCssManifestData(options: { return { styles, - ...(Object.keys(templates).length ? { templates } : {}), + ...(templates ? { templates } : {}), } } @@ -449,13 +492,13 @@ export function buildRouteManifestRoutes(options: { entryChunk: NormalizedClientChunk assetResolvers: ManifestAssetResolvers additionalRouteAssets?: Partial< - Record> + Record> > }) { const routes: Record = {} const getChunkCssAssets = createChunkCssAssetCollector({ chunksByFileName: options.chunksByFileName, - getStylesheetAsset: options.assetResolvers.getStylesheetAsset, + getStylesheetLink: options.assetResolvers.getStylesheetLink, }).getChunkCssAssets for (const [routeId, route] of Object.entries(options.routeTreeRoutes)) { @@ -535,7 +578,7 @@ export function buildRouteManifestRoutes(options: { } const route = (routes[routeId] = routes[routeId] || {}) - route.assets = appendUniqueAssets(route.assets, [...assets]) + appendAdditionalRouteEntries(route, assets) } } @@ -546,19 +589,18 @@ function mergeReachableHydrationChunkData(options: { route: RouteTreeRoute chunk: NormalizedClientChunk chunksByFileName: ReadonlyMap - getChunkCssAssets: (chunk: NormalizedClientChunk) => Array + getChunkCssAssets: (chunk: NormalizedClientChunk) => Array }) { const visitedStaticChunks = new Set() const mergedHydrationChunks = new Set() const mergeHydrationChunk = (chunk: NormalizedClientChunk) => { - if (mergedHydrationChunks.has(chunk.fileName)) return + if (mergedHydrationChunks.has(chunk.fileName)) { + return + } mergedHydrationChunks.add(chunk.fileName) - options.route.assets = appendUniqueAssets( - options.route.assets, - options.getChunkCssAssets(chunk), - ) + appendRouteStylesheets(options.route, options.getChunkCssAssets(chunk)) for (const dynamicImport of chunk.dynamicImports) { const dynamicChunk = options.chunksByFileName.get(dynamicImport) @@ -569,7 +611,9 @@ function mergeReachableHydrationChunkData(options: { } const visitStaticChunk = (chunk: NormalizedClientChunk) => { - if (visitedStaticChunks.has(chunk.fileName)) return + if (visitedStaticChunks.has(chunk.fileName)) { + return + } visitedStaticChunks.add(chunk.fileName) for (const importedFileName of chunk.imports) { @@ -601,10 +645,12 @@ function dedupeNestedRouteManifestEntries( route: DedupeRoute, routesById: Record, seenPreloads = new Set(), - seenAssets = new Set(), + seenScripts = new Set(), + seenStylesheets = new Set(), ) { let routePreloads = route.preloads - let routeAssets = route.assets + let routeScripts = route.scripts + let routeStylesheets = route.css if (routePreloads && routePreloads.length > 0) { let dedupedPreloads: Array | undefined @@ -633,30 +679,65 @@ function dedupeNestedRouteManifestEntries( } } - if (routeAssets && routeAssets.length > 0) { - let dedupedAssets: Array | undefined + if (routeScripts && routeScripts.length > 0) { + let dedupedScripts: Array | undefined - for (let i = 0; i < routeAssets.length; i++) { - const asset = routeAssets[i]! - const identity = getAssetIdentity(asset) + for (let i = 0; i < routeScripts.length; i++) { + const script = routeScripts[i]! + const identity = getScriptIdentity(script) - if (seenAssets.has(identity)) { - if (dedupedAssets === undefined) { - dedupedAssets = routeAssets.slice(0, i) + if (seenScripts.has(identity)) { + if (dedupedScripts === undefined) { + dedupedScripts = routeScripts.slice(0, i) } continue } - seenAssets.add(identity) + seenScripts.add(identity) - if (dedupedAssets) { - dedupedAssets.push(asset) + if (dedupedScripts) { + dedupedScripts.push(script) } } - if (dedupedAssets) { - routeAssets = dedupedAssets - route.assets = dedupedAssets + if (dedupedScripts) { + routeScripts = dedupedScripts + if (dedupedScripts.length > 0) { + route.scripts = dedupedScripts + } else { + delete route.scripts + } + } + } + + if (routeStylesheets && routeStylesheets.length > 0) { + let dedupedStylesheets: Array | undefined + + for (let i = 0; i < routeStylesheets.length; i++) { + const stylesheet = routeStylesheets[i]! + const identity = getStylesheetIdentity(stylesheet) + + if (seenStylesheets.has(identity)) { + if (dedupedStylesheets === undefined) { + dedupedStylesheets = routeStylesheets.slice(0, i) + } + continue + } + + seenStylesheets.add(identity) + + if (dedupedStylesheets) { + dedupedStylesheets.push(stylesheet) + } + } + + if (dedupedStylesheets) { + routeStylesheets = dedupedStylesheets + if (dedupedStylesheets.length > 0) { + route.css = dedupedStylesheets + } else { + delete route.css + } } } @@ -675,7 +756,8 @@ function dedupeNestedRouteManifestEntries( childRoute, routesById, seenPreloads, - seenAssets, + seenScripts, + seenStylesheets, ) } } @@ -686,9 +768,15 @@ function dedupeNestedRouteManifestEntries( } } - if (routeAssets) { - for (let i = routeAssets.length - 1; i >= 0; i--) { - seenAssets.delete(getAssetIdentity(routeAssets[i]!)) + if (routeScripts) { + for (let i = routeScripts.length - 1; i >= 0; i--) { + seenScripts.delete(getScriptIdentity(routeScripts[i]!)) + } + } + + if (routeStylesheets) { + for (let i = routeStylesheets.length - 1; i >= 0; i--) { + seenStylesheets.delete(getStylesheetIdentity(routeStylesheets[i]!)) } } } diff --git a/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts index dc027cf32f..aa2ac40936 100644 --- a/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts @@ -99,10 +99,10 @@ function getViteAdditionalRouteAssets(options: { ) } - const { getStylesheetAsset } = createManifestAssetResolvers(options.basePath) + const { getStylesheetLink } = createManifestAssetResolvers(options.basePath) return { - [rootRouteId]: [getStylesheetAsset(options.cssCodeSplitDisabledFileName)], + [rootRouteId]: [getStylesheetLink(options.cssCodeSplitDisabledFileName)], } } diff --git a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts index 10c5783259..70f174650c 100644 --- a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts +++ b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts @@ -14,6 +14,26 @@ import { } from '../../src/vite/import-protection-plugin/virtualModules' import type { ViolationInfo } from '../../src/import-protection/trace' +type MockValue = { + toString: () => string + valueOf: () => string + toJSON: () => string +} + +type MockFunction = { + (): MockValue +} + +function evaluateGeneratedModule(code: string): T { + const exports: Record = {} + const runnableCode = code + .replace(/export default mock;?/g, 'exports.default = mock;') + .replace(/export const ([A-Za-z_$][\w$]*) = ([^;]+);/g, 'exports.$1 = $2;') + + new Function('exports', runnableCode)(exports) + return exports as T +} + describe('loadSilentMockModule', () => { test('returns mock code', () => { const result = loadSilentMockModule() @@ -23,6 +43,17 @@ describe('loadSilentMockModule', () => { expect(result.code).toContain('@__NO_SIDE_EFFECTS__') expect(result.code).toContain('@__PURE__') }) + + test('supports primitive conversion for called mocks', () => { + const result = loadSilentMockModule() + const mod = evaluateGeneratedModule<{ default: MockFunction }>(result.code) + const value = mod.default() + + expect(String(value)).toBe('[import-protection mock]') + expect(value.toString()).toBe('[import-protection mock]') + expect(value.valueOf()).toBe('[import-protection mock]') + expect(JSON.stringify(value)).toBe('"[import-protection mock]"') + }) }) describe('loadMarkerModule', () => { @@ -268,7 +299,16 @@ describe('generateSelfContainedMockModule', () => { test('is self-contained (no imports)', () => { const result = generateSelfContainedMockModule(['foo']) // Should not import from any other module - expect(result.code).not.toMatch(/\bimport\b/) + expect(result.code).not.toMatch(/^\s*import\s/m) + }) + + test('supports primitive conversion for named export calls', () => { + const result = generateSelfContainedMockModule(['getSecret']) + const mod = evaluateGeneratedModule<{ getSecret: MockFunction }>( + result.code, + ) + + expect(String(mod.getSecret())).toBe('[import-protection mock]') }) }) diff --git a/packages/start-plugin-core/tests/rsbuild-output-directory.test.ts b/packages/start-plugin-core/tests/rsbuild-output-directory.test.ts index 7d5414f22c..90e33fd019 100644 --- a/packages/start-plugin-core/tests/rsbuild-output-directory.test.ts +++ b/packages/start-plugin-core/tests/rsbuild-output-directory.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from 'vitest' -import { resolveRsbuildOutputDirectory } from '../src/rsbuild/planning' +import { + createRsbuildEnvironmentPlan, + resolveRsbuildOutputDirectory, +} from '../src/rsbuild/planning' describe('resolveRsbuildOutputDirectory', () => { test('uses explicit environment distPath string', () => { @@ -57,3 +60,77 @@ describe('resolveRsbuildOutputDirectory', () => { ).toBe('dist/client') }) }) + +describe('createRsbuildEnvironmentPlan client output', () => { + const baseOptions = { + root: '/app', + entryAliases: { + client: '/app/src/client.tsx', + server: '/app/src/server.ts', + start: '/app/src/start.ts', + router: '/app/src/router.tsx', + alias: { + 'virtual:tanstack-start-client-entry': '/app/src/client.tsx', + 'virtual:tanstack-start-server-entry': '/app/src/server.ts', + '#tanstack-start-entry': '/app/src/start.ts', + '#tanstack-router-entry': '/app/src/router.tsx', + }, + }, + clientOutputDirectory: 'dist/client', + serverOutputDirectory: 'dist/server', + publicBase: '/_build/', + serverFnProviderEnv: 'ssr', + } + + test('sets client output.module from scriptFormat', () => { + expect( + createRsbuildEnvironmentPlan({ + ...baseOptions, + scriptFormat: 'iife', + }).environments.client!.output?.module, + ).toBe(false) + + expect( + createRsbuildEnvironmentPlan({ + ...baseOptions, + scriptFormat: 'module', + }).environments.client!.output?.module, + ).toBe(true) + }) + + test('throws when client output.module conflicts with scriptFormat', () => { + expect(() => + createRsbuildEnvironmentPlan({ + ...baseOptions, + scriptFormat: 'iife', + environmentOverrides: { + client: { + output: { + module: true, + }, + }, + }, + }), + ).toThrow( + 'TanStack Start rsbuild.client.output controls environments.client.output.module', + ) + }) + + test('throws when shared output.module conflicts with scriptFormat', () => { + expect(() => + createRsbuildEnvironmentPlan({ + ...baseOptions, + scriptFormat: 'iife', + environmentOverrides: { + all: { + output: { + module: true, + }, + }, + }, + }), + ).toThrow( + 'TanStack Start rsbuild.client.output controls environments.client.output.module', + ) + }) +}) diff --git a/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts b/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts index 1b156c2af5..91bf8ff90d 100644 --- a/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts +++ b/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from 'vitest' import { deserialize } from 'seroval' import { shouldRebaseInlineCssUrls } from '../../src/start-manifest-plugin/inlineCss' import { - appendUniqueAssets, appendUniqueStrings, buildStartManifest, createChunkCssAssetCollector, @@ -14,6 +13,7 @@ import { scanClientChunks, type StartManifest, } from '../../src/start-manifest-plugin/manifestBuilder' +import type { ManifestCssLink } from '@tanstack/router-core' import type { Rollup } from 'vite' function normalizeTestBuild(bundle: Rollup.OutputBundle) { @@ -73,6 +73,14 @@ function makeCssAsset(fileName: string, source: string): Rollup.OutputAsset { } as unknown as Rollup.OutputAsset } +function makeStylesheetLink(href: string): ManifestCssLink { + return href +} + +function getManifestCssHref(link: ManifestCssLink) { + return typeof link === 'string' ? link : link.href +} + describe('getRouteFilePathsFromModuleIds', () => { test('returns unique route file paths only for tsr split modules', () => { expect( @@ -131,112 +139,6 @@ describe('appendUniqueStrings', () => { }) }) -describe('appendUniqueAssets', () => { - test('dedupes by asset identity while preserving order', () => { - const baseAsset = { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - type: 'text/css', - }, - } as const - const duplicateAsset = { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - type: 'text/css', - }, - } as const - const newAsset = { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/b.css', - type: 'text/css', - }, - } as const - - expect(appendUniqueAssets([baseAsset], [duplicateAsset, newAsset])).toEqual( - [baseAsset, newAsset], - ) - }) - - test('returns original target array when nothing new is appended', () => { - const target = [ - { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - type: 'text/css', - }, - }, - ] - - expect( - appendUniqueAssets(target, [ - { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - type: 'text/css', - }, - }, - ]), - ).toBe(target) - }) - - test('keeps distinct link assets with different attributes', () => { - const stylesheetA = { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - media: 'screen', - type: 'text/css', - }, - } - const stylesheetB = { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/a.css', - media: 'print', - type: 'text/css', - }, - } - - expect(appendUniqueAssets([stylesheetA], [stylesheetB])).toEqual([ - stylesheetA, - stylesheetB, - ]) - }) - - test('keeps distinct script assets with different attributes', () => { - const scriptA = { - tag: 'script' as const, - attrs: { - src: '/assets/app.js', - type: 'module', - async: true, - }, - } - const scriptB = { - tag: 'script' as const, - attrs: { - src: '/assets/app.js', - type: 'module', - defer: true, - }, - } - - expect(appendUniqueAssets([scriptA], [scriptB])).toEqual([scriptA, scriptB]) - }) -}) - describe('scanClientChunks', () => { test('collects entry chunk and route chunk mappings', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true }) @@ -283,7 +185,7 @@ describe('scanClientChunks', () => { }) describe('createManifestAssetResolvers + createChunkCssAssetCollector', () => { - test('reuses cached stylesheet assets', () => { + test('reuses cached stylesheet links', () => { const entryChunk = makeChunk({ fileName: 'entry.js', imports: ['shared.js'], @@ -298,37 +200,20 @@ describe('createManifestAssetResolvers + createChunkCssAssetCollector', () => { ['shared.js', normalizeTestChunk(sharedChunk)], ]) - const resolvers = createManifestAssetResolvers('/assets') - const cssAssetCollector = createChunkCssAssetCollector({ + const linkResolvers = createManifestAssetResolvers('/assets') + const cssLinkCollector = createChunkCssAssetCollector({ chunksByFileName, - getStylesheetAsset: resolvers.getStylesheetAsset, + getStylesheetLink: linkResolvers.getStylesheetLink, }) - const assets = cssAssetCollector.getChunkCssAssets( + const links = cssLinkCollector.getChunkCssAssets( chunksByFileName.get('entry.js')!, ) - expect(assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/entry.css', - type: 'text/css', - }, - }, - ]) + expect(links).toEqual(['/assets/shared.css', '/assets/entry.css']) - expect(resolvers.getStylesheetAsset('shared.css')).toBe( - resolvers.getStylesheetAsset('shared.css'), + expect(linkResolvers.getStylesheetLink('shared.css')).toBe( + linkResolvers.getStylesheetLink('shared.css'), ) }) }) @@ -363,15 +248,14 @@ describe('createChunkCssAssetCollector', () => { const { getChunkCssAssets } = createChunkCssAssetCollector({ chunksByFileName, - getStylesheetAsset: (cssFile) => ({ - tag: 'link', - attrs: { rel: 'stylesheet', href: `/${cssFile}`, type: 'text/css' }, + getStylesheetLink: (cssFile) => ({ + href: `/${cssFile}`, }), }) - const assets = getChunkCssAssets(chunksByFileName.get('a.js')!) + const links = getChunkCssAssets(chunksByFileName.get('a.js')!) - expect(assets.map((asset: any) => asset.attrs.href)).toEqual([ + expect(links.map(getManifestCssHref)).toEqual([ '/shared.css', '/b.css', '/c.css', @@ -397,14 +281,13 @@ describe('createChunkCssAssetCollector', () => { const { getChunkCssAssets } = createChunkCssAssetCollector({ chunksByFileName, - getStylesheetAsset: (cssFile) => ({ - tag: 'link', - attrs: { rel: 'stylesheet', href: `/${cssFile}`, type: 'text/css' }, + getStylesheetLink: (cssFile) => ({ + href: `/${cssFile}`, }), }) - const assets = getChunkCssAssets(chunksByFileName.get('a.js')!) - const hrefs = assets.map((a: any) => a.attrs.href) + const links = getChunkCssAssets(chunksByFileName.get('a.js')!) + const hrefs = links.map(getManifestCssHref) expect(hrefs).toContain('/a.css') expect(hrefs).toContain('/b.css') @@ -502,7 +385,7 @@ describe('buildStartManifest', () => { }) }) - test('throws when inline CSS content is missing for a stylesheet asset', () => { + test('throws when inline CSS content is missing for a stylesheet link', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, @@ -523,14 +406,14 @@ describe('buildStartManifest', () => { ).toThrow('could not find CSS content') }) - test('allows callers to attach additional route assets', () => { + test('allows callers to attach additional route stylesheet links', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, moduleIds: ['/src/entry.tsx'], }) - const assetResolvers = createManifestAssetResolvers('/assets') + const linkResolvers = createManifestAssetResolvers('/assets') const manifest = buildStartManifest({ clientBuild: normalizeViteClientBuild({ @@ -542,30 +425,85 @@ describe('buildStartManifest', () => { }, basePath: '/assets', additionalRouteAssets: { - __root__: [assetResolvers.getStylesheetAsset('style.css')], + __root__: [linkResolvers.getStylesheetLink('style.css')], }, }) - expect(manifest.routes.__root__!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/style.css', - type: 'text/css', - }, - }, + expect(manifest.routes.__root__!.css).toEqual([ + makeStylesheetLink('/assets/style.css'), ]) }) - test('rejects additional route assets for unknown route ids', () => { + test('dedupes duplicate additional route scripts on the same route', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, moduleIds: ['/src/entry.tsx'], }) + const script = { + attrs: { + src: '/assets/custom.js', + type: 'module', + }, + } as const + + const manifest = buildStartManifest({ + clientBuild: normalizeViteClientBuild({ + 'entry.js': entryChunk, + }), + routeTreeRoutes: { + __root__: { children: ['/about'] } as any, + '/about': { filePath: '/routes/about.tsx' }, + }, + basePath: '/assets', + additionalRouteAssets: { + __root__: [script, script], + }, + }) - const assetResolvers = createManifestAssetResolvers('/assets') + expect(manifest.routes.__root__!.scripts).toEqual([script]) + }) + + test('dedupes additional route scripts already owned by ancestor routes', () => { + const entryChunk = makeChunk({ + fileName: 'entry.js', + isEntry: true, + moduleIds: ['/src/entry.tsx'], + }) + const script = { + attrs: { + src: '/assets/custom.js', + type: 'module', + }, + } as const + + const manifest = buildStartManifest({ + clientBuild: normalizeViteClientBuild({ + 'entry.js': entryChunk, + }), + routeTreeRoutes: { + __root__: { children: ['/about'] } as any, + '/about': { filePath: '/routes/about.tsx' }, + }, + basePath: '/assets', + additionalRouteAssets: { + __root__: [script], + '/about': [script], + }, + }) + + expect(manifest.routes.__root__!.scripts).toEqual([script]) + expect(manifest.routes['/about']?.scripts).toBeUndefined() + }) + + test('rejects additional route entries for unknown route ids', () => { + const entryChunk = makeChunk({ + fileName: 'entry.js', + isEntry: true, + moduleIds: ['/src/entry.tsx'], + }) + + const linkResolvers = createManifestAssetResolvers('/assets') expect(() => buildStartManifest({ @@ -578,7 +516,7 @@ describe('buildStartManifest', () => { }, basePath: '/assets', additionalRouteAssets: { - '/missing': [assetResolvers.getStylesheetAsset('style.css')], + '/missing': [linkResolvers.getStylesheetLink('style.css')], }, }), ).toThrow( @@ -586,7 +524,7 @@ describe('buildStartManifest', () => { ) }) - test('dedupes route css gathered through overlapping chunk imports', () => { + test('dedupes route CSS gathered through overlapping chunk imports', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, @@ -626,31 +564,10 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes['/about']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/branch-a.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/branch-b.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/about']!.css).toEqual([ + makeStylesheetLink('/assets/shared.css'), + makeStylesheetLink('/assets/branch-a.css'), + makeStylesheetLink('/assets/branch-b.css'), ]) }) @@ -683,15 +600,8 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes['/about']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/about-widget.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/about']!.css).toEqual([ + makeStylesheetLink('/assets/about-widget.css'), ]) expect(manifest.routes['/about']!.preloads).toEqual(['/assets/about.js']) }) @@ -732,23 +642,9 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes['/about']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/parent-widget.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/child-widget.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/about']!.css).toEqual([ + makeStylesheetLink('/assets/parent-widget.css'), + makeStylesheetLink('/assets/child-widget.css'), ]) expect(manifest.routes['/about']!.preloads).toEqual(['/assets/about.js']) }) @@ -794,16 +690,9 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - const expectedAsset = { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/shared-hydrate.css', - type: 'text/css', - }, - } - expect(manifest.routes['/about']!.assets).toEqual([expectedAsset]) - expect(manifest.routes['/posts']!.assets).toEqual([expectedAsset]) + const expectedLink = makeStylesheetLink('/assets/shared-hydrate.css') + expect(manifest.routes['/about']!.css).toEqual([expectedLink]) + expect(manifest.routes['/posts']!.css).toEqual([expectedLink]) expect(manifest.routes['/about']!.preloads).toEqual([ '/assets/about.js', '/assets/shared-widget.js', @@ -843,7 +732,7 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes.__root__!.assets).toBeUndefined() + expect(manifest.routes.__root__!.css).toBeUndefined() expect(manifest.routes.__root__!.preloads).toEqual([ '/assets/entry.js', '/assets/app-shell.js', @@ -894,15 +783,8 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes.__root__!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/root-widget.css', - type: 'text/css', - }, - }, + expect(manifest.routes.__root__!.css).toEqual([ + makeStylesheetLink('/assets/root-widget.css'), ]) expect(manifest.routes.__root__!.preloads).toEqual([ '/assets/root.js', @@ -943,13 +825,11 @@ describe('buildStartManifest', () => { }) expect( - manifest.routes['/field-detail-panel']!.assets!.map( - (asset: any) => asset.attrs.href, - ), + manifest.routes['/field-detail-panel']!.css!.map(getManifestCssHref), ).toEqual(['/assets/tabs.css', '/assets/field-detail-panel.css']) }) - test('dedupes route css already owned by ancestor routes', () => { + test('dedupes route CSS already owned by ancestor routes', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, @@ -980,46 +860,37 @@ describe('buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes.__root__!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/main.css', - type: 'text/css', - }, - }, + expect(manifest.routes.__root__!.css).toEqual([ + makeStylesheetLink('/assets/main.css'), ]) - expect(manifest.routes['/about']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/about.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/about']!.css).toEqual([ + makeStylesheetLink('/assets/about.css'), ]) }) - test('serializeStartManifest preserves shared asset identity across routes', () => { - const sharedAsset = { - tag: 'link' as const, - attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', - }, - } + test('serializeStartManifest preserves shared link identity across routes', () => { + const sharedLink = { + href: '/assets/shared.css', + crossOrigin: 'anonymous', + } satisfies ManifestCssLink const manifest: StartManifest = { routes: { __root__: { children: ['/a', '/b', '/c'], }, - '/a': { assets: [sharedAsset], preloads: ['/assets/a.js'] }, - '/b': { assets: [sharedAsset], preloads: ['/assets/b.js'] }, - '/c': { assets: [sharedAsset], preloads: ['/assets/c.js'] }, + '/a': { + css: [sharedLink], + preloads: ['/assets/a.js'], + }, + '/b': { + css: [sharedLink], + preloads: ['/assets/b.js'], + }, + '/c': { + css: [sharedLink], + preloads: ['/assets/c.js'], + }, }, clientEntry: '/assets/entry.js', } @@ -1028,16 +899,16 @@ describe('buildStartManifest', () => { serializeStartManifest(manifest), ) - const aAsset = evaluated.routes['/a']?.assets?.[0] - const bAsset = evaluated.routes['/b']?.assets?.[0] - const cAsset = evaluated.routes['/c']?.assets?.[0] + const aLink = evaluated.routes['/a']?.css?.[0] + const bLink = evaluated.routes['/b']?.css?.[0] + const cLink = evaluated.routes['/c']?.css?.[0] - expect(aAsset).toBeDefined() - expect(aAsset).toBe(bAsset) - expect(bAsset).toBe(cAsset) + expect(aLink).toBeDefined() + expect(aLink).toBe(bLink) + expect(bLink).toBe(cLink) }) - test('serializeStartManifest preserves non-asset fields unchanged', () => { + test('serializeStartManifest preserves non-link fields unchanged', () => { const manifest: StartManifest = { routes: { __root__: { @@ -1048,9 +919,8 @@ describe('buildStartManifest', () => { filePath: '/routes/posts.tsx', children: ['/posts/$postId'], preloads: ['/assets/posts.js'], - assets: [ + scripts: [ { - tag: 'script' as const, attrs: { src: '/assets/posts.js', type: 'module', @@ -1068,7 +938,7 @@ describe('buildStartManifest', () => { ).toEqual(manifest) }) - test('serializeStartManifest handles manifests without route assets', () => { + test('serializeStartManifest handles manifests without route links', () => { const manifest: StartManifest = { routes: { __root__: { @@ -1087,10 +957,40 @@ describe('buildStartManifest', () => { deserializeSerializedManifest(serializeStartManifest(manifest)), ).toEqual(manifest) }) + + test('buildStartManifest includes scriptFormat only for iife output', () => { + const entryChunk = makeChunk({ + fileName: 'entry.js', + isEntry: true, + }) + const routeTreeRoutes = { + __root__: { + filePath: '/routes/__root.tsx', + }, + } + + expect( + buildStartManifest({ + clientBuild: normalizeTestBuild({ 'entry.js': entryChunk }), + routeTreeRoutes, + basePath: '/assets', + scriptFormat: 'module', + }).scriptFormat, + ).toBeUndefined() + + expect( + buildStartManifest({ + clientBuild: normalizeTestBuild({ 'entry.js': entryChunk }), + routeTreeRoutes, + basePath: '/assets', + scriptFormat: 'iife', + }).scriptFormat, + ).toBe('iife') + }) }) describe('route tree dedupe in buildStartManifest', () => { - test('dedupes assets and preloads only along the active ancestor path', () => { + test('dedupes stylesheet links and preloads only along the active ancestor path', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, @@ -1149,65 +1049,30 @@ describe('route tree dedupe in buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes.__root__!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/shared.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/root.css', - type: 'text/css', - }, - }, + expect(manifest.routes.__root__!.css).toEqual([ + makeStylesheetLink('/assets/shared.css'), + makeStylesheetLink('/assets/root.css'), ]) expect(manifest.routes.__root__!.preloads).toEqual([ '/assets/entry.js', '/assets/root-shared.js', ]) - expect(manifest.routes['/parent']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/parent.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/parent']!.css).toEqual([ + makeStylesheetLink('/assets/parent.css'), ]) expect(manifest.routes['/parent']!.preloads).toEqual([ '/assets/parent.js', '/assets/parent-only.js', ]) - expect(manifest.routes['/child']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/child.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/child']!.css).toEqual([ + makeStylesheetLink('/assets/child.css'), ]) expect(manifest.routes['/child']!.preloads).toEqual([ '/assets/child.js', '/assets/child-only.js', ]) - expect(manifest.routes['/sibling']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/sibling.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/sibling']!.css).toEqual([ + makeStylesheetLink('/assets/sibling.css'), ]) expect(manifest.routes['/sibling']!.preloads).toEqual([ '/assets/sibling.js', @@ -1267,37 +1132,16 @@ describe('route tree dedupe in buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes['/a-child']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/deep.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/a-child']!.css).toEqual([ + makeStylesheetLink('/assets/deep.css'), ]) expect(manifest.routes['/a-child']!.preloads).toEqual([ '/assets/a-child.js', '/assets/deep.js', ]) - expect(manifest.routes['/b-child']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/deep.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/b-child.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/b-child']!.css).toEqual([ + makeStylesheetLink('/assets/deep.css'), + makeStylesheetLink('/assets/b-child.css'), ]) expect(manifest.routes['/b-child']!.preloads).toEqual([ '/assets/b-child.js', @@ -1376,65 +1220,30 @@ describe('route tree dedupe in buildStartManifest', () => { basePath: '/assets', }) - expect(manifest.routes.__root__!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/shared-root.css', - type: 'text/css', - }, - }, - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/root.css', - type: 'text/css', - }, - }, + expect(manifest.routes.__root__!.css).toEqual([ + makeStylesheetLink('/assets/shared-root.css'), + makeStylesheetLink('/assets/root.css'), ]) expect(manifest.routes.__root__!.preloads).toEqual([ '/assets/entry.js', '/assets/shared-root.js', ]) - expect(manifest.routes['/level-one']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/level-one.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/level-one']!.css).toEqual([ + makeStylesheetLink('/assets/level-one.css'), ]) expect(manifest.routes['/level-one']!.preloads).toEqual([ '/assets/level-one.js', '/assets/level-one-only.js', ]) - expect(manifest.routes['/level-two']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/level-two.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/level-two']!.css).toEqual([ + makeStylesheetLink('/assets/level-two.css'), ]) expect(manifest.routes['/level-two']!.preloads).toEqual([ '/assets/level-two.js', '/assets/level-two-only.js', ]) - expect(manifest.routes['/level-three']!.assets).toEqual([ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/assets/level-three.css', - type: 'text/css', - }, - }, + expect(manifest.routes['/level-three']!.css).toEqual([ + makeStylesheetLink('/assets/level-three.css'), ]) expect(manifest.routes['/level-three']!.preloads).toEqual([ '/assets/level-three.js', @@ -1526,7 +1335,7 @@ describe('multi-chunk routes must merge assets and preloads', () => { const postsRoute = manifest.routes['/posts']! // Both chunks' CSS should be present - const cssHrefs = postsRoute.assets!.map((a: any) => a.attrs.href) + const cssHrefs = postsRoute.css!.map(getManifestCssHref) expect(cssHrefs).toContain('/assets/component.css') expect(cssHrefs).toContain('/assets/loader.css') @@ -1573,7 +1382,7 @@ describe('multi-chunk routes must merge assets and preloads', () => { }) const postsRoute = manifest.routes['/posts']! - const cssHrefs = postsRoute.assets!.map((a: any) => a.attrs.href) + const cssHrefs = postsRoute.css!.map(getManifestCssHref) const preloadHrefs = postsRoute.preloads! expect( @@ -1586,7 +1395,7 @@ describe('multi-chunk routes must merge assets and preloads', () => { }) describe('buildStartManifest route pruning', () => { - test('routes with no assets or preloads are pruned from returned manifest', () => { + test('routes with no manifest data are pruned from returned manifest', () => { const entryChunk = makeChunk({ fileName: 'entry.js', isEntry: true, @@ -1604,7 +1413,7 @@ describe('buildStartManifest route pruning', () => { basePath: '/assets', }) - // /about has no matching chunks, so no assets or preloads. + // /about has no matching chunks, so no manifest data. // It should be pruned from the manifest. expect(manifest.routes['/about']).toBeUndefined() }) diff --git a/packages/start-plugin-core/vite.config.ts b/packages/start-plugin-core/vite.config.ts index 5f52ed13b3..fc76204069 100644 --- a/packages/start-plugin-core/vite.config.ts +++ b/packages/start-plugin-core/vite.config.ts @@ -21,6 +21,7 @@ export default mergeConfig( './src/vite/index.ts', './src/rsbuild/index.ts', './src/rsbuild/types.ts', + './src/rsbuild/start-compiler-metadata-loader.ts', ], srcDir: './src', outDir: './dist', diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 7d47388a3c..0952576051 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -497,7 +497,6 @@ export function createStartHandler( manifest, getRequestAssets: () => getStartContext({ throwIfNotFound: false })?.requestAssets, - includeUnmatchedRouteAssets: false, }) routerInstance.update({ additionalContext: { serverContext } }) diff --git a/packages/start-server-core/src/early-hints.ts b/packages/start-server-core/src/early-hints.ts index 3504150929..f46cf5f587 100644 --- a/packages/start-server-core/src/early-hints.ts +++ b/packages/start-server-core/src/early-hints.ts @@ -1,13 +1,14 @@ import { + getScriptPreloadAttrs, getStylesheetHref, - resolveManifestAssetLink, + resolveManifestCssLink, } from '@tanstack/router-core' import type { AnyRoute, AnyRouteMatch, AssetCrossOrigin, - Manifest, RouterManagedTag, + ServerManifest, } from '@tanstack/router-core' export type EarlyHint = { @@ -49,7 +50,7 @@ export type ResponseLinkHeaderOptions = { export interface EarlyHintsCollector { collectStatic: (opts: { - manifest: Manifest + manifest: ServerManifest matchedRoutes?: ReadonlyArray }) => void collectDynamic: (matches: ReadonlyArray) => void @@ -174,7 +175,7 @@ function linkAttrsToEarlyHint( } export function collectStaticHintsFromManifest( - manifest: Manifest, + manifest: ServerManifest, matchedRoutes: ReadonlyArray, ): Array { const hints: Array = [] @@ -184,33 +185,32 @@ export function collectStaticHintsFromManifest( if (!routeManifest) continue for (const link of routeManifest.preloads ?? []) { - const { href, crossOrigin } = resolveManifestAssetLink(link) - const hint: EarlyHint = { href, rel: 'modulepreload', as: 'script' } - if (crossOrigin !== undefined) hint.crossOrigin = crossOrigin + const attrs = getScriptPreloadAttrs(manifest, link) + const hint: EarlyHint = { + href: attrs.href, + rel: attrs.rel, + as: 'script', + } + if (attrs.crossOrigin !== undefined) hint.crossOrigin = attrs.crossOrigin hints.push(hint) } - for (const asset of routeManifest.assets ?? []) { - if (asset.tag !== 'link') continue - - const stylesheetHref = getStylesheetHref(asset) - if (stylesheetHref) { - if (manifest.inlineCss?.styles[stylesheetHref] !== undefined) continue - - const hint: EarlyHint = { - href: stylesheetHref, - rel: 'preload', - as: 'style', - } - addEarlyHintFetchAttrs(hint, asset.attrs) - hints.push(hint) + for (const link of routeManifest.css ?? []) { + const stylesheetHref = getStylesheetHref(link) + if (manifest.inlineCss?.styles[stylesheetHref] !== undefined) { continue } + const resolvedLink = resolveManifestCssLink(link) - const hint = linkAttrsToEarlyHint(asset.attrs) - if (hint) { - hints.push(hint) + const hint: EarlyHint = { + href: stylesheetHref, + rel: 'preload', + as: 'style', } + if (resolvedLink.crossOrigin !== undefined) { + hint.crossOrigin = resolvedLink.crossOrigin + } + hints.push(hint) } } diff --git a/packages/start-server-core/src/finalManifest.ts b/packages/start-server-core/src/finalManifest.ts index 09976ee631..a1edd4fd79 100644 --- a/packages/start-server-core/src/finalManifest.ts +++ b/packages/start-server-core/src/finalManifest.ts @@ -7,7 +7,7 @@ import { getStaticHandlerInlineCssDefault, resolveInlineCssForRequest, } from './inlineCss' -import type { Manifest } from '@tanstack/router-core' +import type { ServerManifest } from '@tanstack/router-core' import type { HandlerInlineCssOption } from './inlineCss' import type { CreateTransformAssetsContext, @@ -45,7 +45,7 @@ export interface FinalManifestOptions { } type FinalManifestCacheKey = 'inline-css' | 'linked-css' -type FinalManifestCache = Map> +type FinalManifestCache = Map> export type GetBaseManifest = () => Promise export interface FinalManifestRequestOptions { @@ -66,9 +66,11 @@ interface FinalManifestTransformResolver { export interface FinalManifestResolver { warmup: (opts: { getBaseManifest: GetBaseManifest - }) => Promise | undefined - resolveCached: (opts: FinalManifestRequestOptions) => Promise - resolveUncached: (opts: FinalManifestRequestOptions) => Promise + }) => Promise | undefined + resolveCached: (opts: FinalManifestRequestOptions) => Promise + resolveUncached: ( + opts: FinalManifestRequestOptions, + ) => Promise } export function createCachedBaseManifestLoader( @@ -206,8 +208,8 @@ function getFinalManifestCacheKey(inlineCss: boolean): FinalManifestCacheKey { function cacheFinalManifestPromise( cachedFinalManifestPromises: FinalManifestCache, cacheKey: FinalManifestCacheKey, - promise: Promise, -): Promise { + promise: Promise, +): Promise { const cachedFinalManifestPromise = promise.catch((error) => { if ( cachedFinalManifestPromises.get(cacheKey) === cachedFinalManifestPromise @@ -224,8 +226,8 @@ function cacheFinalManifestPromise( function getOrCreateCachedFinalManifestPromise( cachedFinalManifestPromises: FinalManifestCache, cacheKey: FinalManifestCacheKey, - computeFinalManifest: () => Promise, -): Promise { + computeFinalManifest: () => Promise, +): Promise { const cachedFinalManifestPromise = cachedFinalManifestPromises.get(cacheKey) if (cachedFinalManifestPromise) { return cachedFinalManifestPromise @@ -242,7 +244,7 @@ async function buildFinalManifest(opts: { base: StartManifestWithClientEntry transformFn: TransformAssetsFn | undefined inlineCss: boolean -}): Promise { +}): Promise { return opts.transformFn ? await transformManifestAssets(opts.base, opts.transformFn, { inlineCss: opts.inlineCss, @@ -256,7 +258,7 @@ async function resolveFinalManifest(opts: { cache: boolean inlineCss: boolean finalManifestCache?: FinalManifestCache -}): Promise { +}): Promise { const computeFinalManifest = async () => { return buildFinalManifest({ base: await opts.getBaseManifest(), @@ -284,7 +286,7 @@ function warmupFinalManifest(opts: { getBaseManifest: () => Promise getTransformFn: () => Promise onError?: () => void -}): Promise | undefined { +}): Promise | undefined { if ( !opts.enabled || opts.handlerDefaultInlineCss === undefined || diff --git a/packages/start-server-core/src/request-handler.ts b/packages/start-server-core/src/request-handler.ts index e6729cd137..0c491e00c9 100644 --- a/packages/start-server-core/src/request-handler.ts +++ b/packages/start-server-core/src/request-handler.ts @@ -9,7 +9,8 @@ type EarlyHintsOptions = { * Fire-and-forget callback for HTTP 103 Early Hints. * Only invoked in production (when TSS_DEV_SERVER !== 'true'). * - * The `static` phase contains transformed manifest assets for matched routes. + * The `static` phase contains transformed manifest preloads and stylesheets + * for matched routes. * The `dynamic` phase runs after route load, is skipped for redirects, and * can contain route `head().links` or empty `hints` and `links` arrays. * `hints` and `links` contain only values not emitted in earlier phases. diff --git a/packages/start-server-core/src/router-manifest.ts b/packages/start-server-core/src/router-manifest.ts index f15b689c7d..451decd6a8 100644 --- a/packages/start-server-core/src/router-manifest.ts +++ b/packages/start-server-core/src/router-manifest.ts @@ -1,16 +1,15 @@ -import { buildDevStylesUrl, rootRouteId } from '@tanstack/router-core' -import type { - AnyRoute, - ManifestAssetLink, - RouterManagedTag, +import { + DEV_STYLES_ATTR, + buildDevStylesUrl, + rootRouteId, } from '@tanstack/router-core' +import type { AnyRoute, ServerManifestRoute } from '@tanstack/router-core' import type { StartManifestWithClientEntry } from './transformAssetUrls' // Pre-computed constant for dev styles URL basepath. // Defaults to vite `base` (set via TSS_DEV_SSR_STYLES_BASEPATH in the plugin), // aligning dev styles with how other CSS/JS assets are served. const DEV_SSR_STYLES_BASEPATH = process.env.TSS_DEV_SSR_STYLES_BASEPATH || '/' - /** * @description Returns the router manifest data that should be sent to the client. * This includes only the assets and preloads for the current route and any @@ -28,11 +27,16 @@ export async function getStartManifest( ): Promise { const { tsrStartManifest } = await import('tanstack-start-manifest:v') const startManifest = tsrStartManifest() + let routes = startManifest.routes + let rootRoute = routes[rootRouteId] - const rootRoute = (startManifest.routes[rootRouteId] = - startManifest.routes[rootRouteId] || {}) - - rootRoute.assets = rootRoute.assets || [] + const updateRootRoute = (nextRootRoute: ServerManifestRoute) => { + rootRoute = nextRootRoute + routes = { + ...routes, + [rootRouteId]: rootRoute, + } + } // Inject dev styles link in dev mode (when SSR styles are enabled) if ( @@ -41,54 +45,66 @@ export async function getStartManifest( matchedRoutes ) { const matchedRouteIds = matchedRoutes.map((route) => route.id) - rootRoute.assets.push({ - tag: 'link', - attrs: { - rel: 'stylesheet', - href: buildDevStylesUrl(DEV_SSR_STYLES_BASEPATH, matchedRouteIds), - 'data-tanstack-router-dev-styles': 'true', - }, + updateRootRoute({ + ...rootRoute, + css: [ + ...(rootRoute?.css ?? []), + { + href: buildDevStylesUrl(DEV_SSR_STYLES_BASEPATH, matchedRouteIds), + [DEV_STYLES_ATTR]: true, + }, + ], }) } - // Collect injected head scripts in dev mode (returned separately so we can - // build the client entry script tag after URL transforms are applied) - let injectedHeadScripts: string | undefined if (process.env.TSS_DEV_SERVER === 'true') { const mod = await import('tanstack-start-injected-head-scripts:v') if (mod.injectedHeadScripts) { - injectedHeadScripts = mod.injectedHeadScripts + updateRootRoute({ + ...rootRoute, + scripts: [ + ...(rootRoute?.scripts ?? []), + { + attrs: { + type: 'module', + }, + children: mod.injectedHeadScripts, + }, + ], + }) + } + } + + const manifestRoutes: Record = {} + + for (const k in routes) { + const v = routes[k]! + const result = {} as ServerManifestRoute + + if (v.preloads && v.preloads.length > 0) { + result.preloads = v.preloads + } + if (v.scripts && v.scripts.length > 0) { + result.scripts = v.scripts + } + if (v.css?.length) { + result.css = v.css + } + if (result.preloads || result.scripts || result.css) { + manifestRoutes[k] = result } } const manifest = { - inlineCss: startManifest.inlineCss, - routes: Object.fromEntries( - Object.entries(startManifest.routes).flatMap(([k, v]) => { - const result = {} as { - preloads?: Array - assets?: Array - } - let hasData = false - if (v.preloads && v.preloads.length > 0) { - result['preloads'] = v.preloads - hasData = true - } - if (v.assets && v.assets.length > 0) { - result['assets'] = v.assets - hasData = true - } - if (!hasData) { - return [] - } - return [[k, result]] - }), - ), + ...(startManifest.scriptFormat + ? { scriptFormat: startManifest.scriptFormat } + : {}), + ...(startManifest.inlineCss ? { inlineCss: startManifest.inlineCss } : {}), + routes: manifestRoutes, } return { manifest: manifest as StartManifestWithClientEntry['manifest'], clientEntry: startManifest.clientEntry, - injectedHeadScripts, } } diff --git a/packages/start-server-core/src/tanstack-start.d.ts b/packages/start-server-core/src/tanstack-start.d.ts index 3cf079c02a..d4aa2b6d42 100644 --- a/packages/start-server-core/src/tanstack-start.d.ts +++ b/packages/start-server-core/src/tanstack-start.d.ts @@ -1,7 +1,7 @@ declare module 'tanstack-start-manifest:v' { - import type { Manifest } from '@tanstack/router-core' + import type { ServerManifest } from '@tanstack/router-core' - export const tsrStartManifest: () => Manifest & { clientEntry: string } + export const tsrStartManifest: () => ServerManifest & { clientEntry: string } } declare module 'tanstack-start-route-tree:v' { diff --git a/packages/start-server-core/src/transformAssetUrls.ts b/packages/start-server-core/src/transformAssetUrls.ts index 2b387fbb7a..c2b03ad9e3 100644 --- a/packages/start-server-core/src/transformAssetUrls.ts +++ b/packages/start-server-core/src/transformAssetUrls.ts @@ -1,11 +1,17 @@ -import { resolveManifestAssetLink, rootRouteId } from '@tanstack/router-core' +import { + getManifestScriptFormat, + resolveManifestAssetLink, + resolveManifestCssLink, +} from '@tanstack/router-core' import type { AssetCrossOrigin, Awaitable, - Manifest, ManifestAssetLink, - RouterManagedTag, + ManifestCssLink, + ManifestScript, + ScriptFormat, + ServerManifest, } from '@tanstack/router-core' export type { AssetCrossOrigin } @@ -13,16 +19,12 @@ export type { AssetCrossOrigin } export type TransformAssetsContext = | { url: string - kind: 'modulepreload' + kind: 'script' } | { url: string kind: 'stylesheet' } - | { - url: string - kind: 'clientEntry' - } | { url: string kind: 'css-url' @@ -33,7 +35,7 @@ export type TransformAssetKind = TransformAssetsContext['kind'] type TransformAssetsShorthandCrossOriginKind = Exclude< TransformAssetKind, - 'clientEntry' | 'css-url' + 'css-url' > export type TransformAssetResult = @@ -114,7 +116,7 @@ export type TransformAssetsOptions = * crossOrigin: 'anonymous' * * // Different values per kind - * crossOrigin: { modulepreload: 'anonymous', stylesheet: 'use-credentials' } + * crossOrigin: { script: 'anonymous', stylesheet: 'use-credentials' } * ``` */ export type TransformAssetsCrossOriginConfig = @@ -136,7 +138,7 @@ export interface TransformAssetsObjectShorthand { /** URL prefix prepended to every asset URL. */ prefix: string /** - * Optional crossOrigin attribute applied to manifest-managed `` assets. + * Optional crossOrigin attribute applied to transformed script and stylesheet assets. * * Accepts a single value or a per-kind record. */ @@ -211,7 +213,7 @@ async function transformInlineCssTemplate(options: { } async function transformInlineCssStyles( - inlineCss: NonNullable, + inlineCss: NonNullable, transformFn: TransformAssetsFn, ) { const transformedStyles: Record = {} @@ -287,7 +289,7 @@ export function resolveTransformAssetsConfig( transformFn: ({ url, kind }) => { const href = `${prefix}${url}` - if (kind === 'clientEntry' || kind === 'css-url') { + if (kind === 'css-url') { return { href } } @@ -321,44 +323,104 @@ export function resolveTransformAssetsConfig( } export interface StartManifestWithClientEntry { - manifest: Manifest + manifest: ServerManifest clientEntry: string - /** Script content prepended before the client entry import (dev only) */ - injectedHeadScripts?: string } /** * Builds the client entry `