Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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,12 +5,12 @@ import { DEV_CLIENT_ENTRY, START_ENVIRONMENT_NAMES } from '../../constants'
import {
buildStartManifest,
createManifestAssetResolvers,
normalizeViteClientBuild,
serializeStartManifest,
} from '../../start-manifest-plugin/manifestBuilder'
import { createVirtualModule } from '../createVirtualModule'
import type { GetConfigFn, NormalizedClientBuild } from '../../types'
import type { PluginOption, Rollup } from 'vite'
import { normalizeViteClientBuild } from './normalized-client-build'

type StartManifestEnvironment = {
config: {
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