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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/seven-times-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/start-plugin-core': patch
'@tanstack/start-server-core': patch
---

fix(start): emit client entry scripts from the root route manifest. When `scriptFormat: 'iife'` and the entry chunk has static-import siblings (e.g. an extracted runtime via `optimization.runtimeChunk: 'single'`), the manifest now includes those async siblings before the async client entry in root route scripts, fixing hydration for IIFE bundles with extracted runtimes.
59 changes: 57 additions & 2 deletions e2e/react-start/custom-server-rsbuild/rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,65 @@
import path from 'node:path'
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'

// Stress-test fixture for `<Scripts />` and the start manifest under a
// non-default client chunk layout. The combination that matters for the
// repro is:
//
// - `client.output: 'iife'` — emit the client entry as a self-executing
// script. The manifest uses plain script tags and classic script preloads;
// setting IIFE here exercises that non-module asset path.
//
// - Client `runtimeChunk: 'single'` — extracts the webpack runtime into
// its own chunk. With IIFE plain scripts, the entry can't bootstrap
// until the runtime has executed, so `<Scripts />` has to emit a
// `<script>` for the runtime chunk (not just a preload). This was
// the regression this fixture covers.
//
// - `client.distPath.root` + `distPath.js: ''` — flat layout, JS at the
// dist root. Matches the path `express-server.ts` serves via
// `express.static('dist/client')`.
//
// - `performance.buildCache: true` — exercise the rspack persistent
// cache, including warm-restart paths.
//
// - `output.assetPrefix: '/static/'` — force manifest URLs through an
// explicit prefix.
export default defineConfig({
plugins: [
pluginReact({ splitChunks: false }),
tanstackStart({ rsbuild: { installDevServerMiddleware: false } }),
pluginReact(),
tanstackStart({
rsbuild: {
installDevServerMiddleware: false,
client: {
output: 'iife',
},
},
}),
],
performance: {
buildCache: true,
},
output: {
assetPrefix: '/static/',
},
environments: {
client: {
output: {
distPath: {
root: path.resolve(__dirname, 'dist/client'),
js: '',
},
},
tools: {
rspack: (config) => {
config.optimization = {
...(config.optimization ?? {}),
runtimeChunk: 'single',
}
},
},
},
},
})
17 changes: 15 additions & 2 deletions packages/start-plugin-core/src/rsbuild/virtual-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,27 @@ function getScriptFormatProperty(scriptFormat: ScriptFormat): string {
return scriptFormat === 'iife' ? ` scriptFormat: 'iife',\n` : ''
}

function getEntryScriptAttrs(
entryUrl: string,
scriptFormat: ScriptFormat,
): string {
return scriptFormat === 'module'
? `{ type: 'module', async: true, src: '${entryUrl}' }`
: `{ async: true, src: '${entryUrl}' }`
}

function generateManifestModuleDev(
devClientEntryUrl: string,
scriptFormat: ScriptFormat,
): string {
const scriptFormatProperty = getScriptFormatProperty(scriptFormat)
return `const fallbackManifest = {
${scriptFormatProperty} routes: {},
clientEntry: '${devClientEntryUrl}',
${scriptFormatProperty} routes: {
__root__: {
preloads: ['${devClientEntryUrl}'],
scripts: [{ attrs: ${getEntryScriptAttrs(devClientEntryUrl, scriptFormat)} }],
},
},
}
export const tsrStartManifest = () => globalThis[${JSON.stringify(DEV_START_MANIFEST_GLOBAL)}] ?? fallbackManifest`
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,6 @@ import {
resolveManifestCssLink,
rootRouteId,
} from '@tanstack/router-core'
import {
getRouteFilePathsFromModuleIds,
normalizeViteClientBuild,
normalizeViteClientChunk,
} from '../vite/start-manifest-plugin/normalized-client-build'
import { processInlineCssUrls } from './inlineCss'
import type {
ManifestAssetLink,
Expand Down Expand Up @@ -58,7 +53,6 @@ type DedupeRoute = {
export interface StartManifest {
scriptFormat?: ScriptFormat
routes: Record<string, RouteTreeRoute>
clientEntry: string
inlineCss?: {
styles: Record<string, string>
templates?: Record<string, InlineCssTemplate>
Expand Down Expand Up @@ -187,6 +181,56 @@ function appendRouteStylesheets(
route.css = appendUniqueStylesheets(route.css, stylesheets)
}

function appendRouteScripts(
route: RouteTreeRoute,
scripts: Array<ManifestScript>,
) {
if (scripts.length === 0) {
return
}

route.scripts = [...(route.scripts ?? []), ...scripts]
}

function buildScript(src: string, scriptFormat: ScriptFormat): ManifestScript {
return {
attrs: {
...(scriptFormat === 'module' ? { type: 'module' } : {}),
async: true,
src,
},
}
}

function appendEntryChunkScripts(options: {
route: RouteTreeRoute
chunk: NormalizedClientChunk
scriptFormat: ScriptFormat
getAssetPath: (fileName: string) => string
}) {
const scripts: Array<ManifestScript> = []

if (options.scriptFormat === 'iife') {
for (let i = 0; i < options.chunk.imports.length; i++) {
scripts.push(
buildScript(
options.getAssetPath(options.chunk.imports[i]!),
options.scriptFormat,
),
)
}
}

scripts.push(
buildScript(
options.getAssetPath(options.chunk.fileName),
options.scriptFormat,
),
)

appendRouteScripts(options.route, scripts)
}

function appendAdditionalRouteEntries(
route: RouteTreeRoute,
entries: ReadonlyArray<AdditionalRouteManifestEntry>,
Expand All @@ -207,9 +251,7 @@ function appendAdditionalRouteEntries(
}

appendRouteStylesheets(route, stylesheets)
if (scripts.length > 0) {
route.scripts = [...(route.scripts ?? []), ...scripts]
}
appendRouteScripts(route, scripts)
}

export function buildStartManifest(options: {
Expand All @@ -234,6 +276,13 @@ export function buildStartManifest(options: {
additionalRouteAssets: options.additionalRouteAssets,
})

appendEntryChunkScripts({
route: routes[rootRouteId]!,
chunk: scannedChunks.entryChunk,
scriptFormat: options.scriptFormat ?? 'module',
getAssetPath: assetResolvers.getAssetPath,
})

dedupeNestedRouteManifestEntries(rootRouteId, routes[rootRouteId]!, routes)

// Prune routes with no manifest data
Expand All @@ -249,7 +298,6 @@ export function buildStartManifest(options: {

const result: StartManifest = {
routes,
clientEntry: assetResolvers.getAssetPath(scannedChunks.entryChunk.fileName),
}

if (options.scriptFormat === 'iife') {
Expand Down Expand Up @@ -504,7 +552,7 @@ export function buildRouteManifestRoutes(options: {
for (const [routeId, route] of Object.entries(options.routeTreeRoutes)) {
if (!route.filePath) {
if (routeId === rootRouteId) {
routes[routeId] = route
routes[routeId] = { ...route }
continue
}

Expand All @@ -513,7 +561,7 @@ export function buildRouteManifestRoutes(options: {

const chunks = options.routeChunksByFilePath.get(route.filePath)
if (!chunks) {
routes[routeId] = route
routes[routeId] = { ...route }
continue
}

Expand Down Expand Up @@ -634,12 +682,6 @@ function mergeReachableHydrationChunkData(options: {
visitStaticChunk(options.chunk)
}

export {
getRouteFilePathsFromModuleIds,
normalizeViteClientBuild,
normalizeViteClientChunk,
}

function dedupeNestedRouteManifestEntries(
routeId: string,
route: DedupeRoute,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { DEV_CLIENT_ENTRY, START_ENVIRONMENT_NAMES } from '../../constants'
import {
buildStartManifest,
createManifestAssetResolvers,
normalizeViteClientBuild,
serializeStartManifest,
} from '../../start-manifest-plugin/manifestBuilder'
import { createVirtualModule } from '../createVirtualModule'
import { normalizeViteClientBuild } from './normalized-client-build'
import type { GetConfigFn, NormalizedClientBuild } from '../../types'
import type { PluginOption, Rollup } from 'vite'

Expand Down Expand Up @@ -137,8 +137,12 @@ function getAssetFileNameByName(

function getEmptyStartManifestModule(clientEntry: string) {
return `export const tsrStartManifest = () => ({
routes: {},
clientEntry: '${clientEntry}',
routes: {
__root__: {
preloads: ['${clientEntry}'],
scripts: [{ attrs: { type: 'module', async: true, src: '${clientEntry}' } }],
},
},
})`
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ vi.mock('@tanstack/start-server-core', () => ({
describe('startManifestPlugin', () => {
test('uses the virtual client entry during unbundled dev', () => {
expect(loadDevManifest({ bundledDev: false })).toContain(
`clientEntry: '/@id/${DEV_CLIENT_ENTRY}'`,
`src: '/@id/${DEV_CLIENT_ENTRY}'`,
)
})

test('uses the bundled client entry during bundled dev', () => {
expect(loadDevManifest({ bundledDev: true })).toContain(
`clientEntry: '/assets/index.js'`,
`src: '/assets/index.js'`,
)
})
})
Expand Down
Loading
Loading