From ce696ad6c75779375ce6620e5c7efdd821359a12 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Wed, 21 Oct 2026 10:14:32 -0700 Subject: [PATCH 1/4] fix(start-plugin-core): intersect Rsbuild static chunk availability across loading paths A shared chunk recorded static imports to every chunk in the union of its groups and their ancestors, so it pointed at each sibling route's copy of a duplicated module. Compute availability per chunk group as its own chunks plus the intersection of its parents, iterate to a shrinking fixed point for cyclic async graphs, and intersect across the chunk's groups. --- .../rsbuild-static-chunk-availability.md | 5 + .../src/rsbuild/normalized-client-build.ts | 105 +++++++- .../rsbuild/normalized-client-build.test.ts | 254 ++++++++++++++++++ 3 files changed, 350 insertions(+), 14 deletions(-) create mode 100644 .changeset/rsbuild-static-chunk-availability.md diff --git a/.changeset/rsbuild-static-chunk-availability.md b/.changeset/rsbuild-static-chunk-availability.md new file mode 100644 index 0000000000..92392438bc --- /dev/null +++ b/.changeset/rsbuild-static-chunk-availability.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Record an Rsbuild static chunk import only when the chunk is loaded on every path to the importing chunk, so a shared chunk no longer links one route's lazy CSS and preloads into its sibling routes. diff --git a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts index 591d6b0113..74fdac548a 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -14,7 +14,12 @@ type ProcessAssetsContext = Parameters< >[0] type RspackCompilation = Rspack.Compilation type RspackCompilationChunk = Rspack.Chunk +type RspackChunkGroup = Rspack.ChunkGroup type RspackModule = Rspack.Module +type ChunkGroupAvailability = Map< + RspackChunkGroup, + ReadonlySet +> const backslashRegex = /\\/g @@ -211,6 +216,75 @@ function computeDynamicImports(chunk: RspackCompilationChunk): Array { return dynamicImportFiles } +function intersect( + left: ReadonlySet, + right: ReadonlySet, +): ReadonlySet { + const result = new Set() + for (const value of left) { + if (right.has(value)) { + result.add(value) + } + } + return result +} + +/** + * Compute the chunks loaded whenever each chunk group loads: the group's own + * chunks plus the intersection of its parents' availability. Rspack's code + * splitter applies the same rule to modules. Entrypoints supply only their own + * chunks. + */ +function computeChunkGroupAvailability( + compilation: RspackCompilation, +): ChunkGroupAvailability { + const groups = compilation.chunkGroups.map((group) => ({ + group, + chunks: group.chunks, + parents: group.isInitial() ? [] : group.getParents(), + })) + + // A group missing from `availability` is not yet known and constrains no + // child. Known sets only shrink, so cyclic async graphs converge to the same + // result in any visiting order. + const availability: ChunkGroupAvailability = new Map() + let changed = true + while (changed) { + changed = false + for (const { group, chunks, parents } of groups) { + let inherited: ReadonlySet | undefined + for (const parent of parents) { + const parentAvailable = availability.get(parent) + if (parentAvailable) { + inherited = inherited + ? intersect(inherited, parentAvailable) + : parentAvailable + } + } + if (parents.length > 0 && !inherited) { + continue + } + + const available = new Set(inherited) + for (const chunk of chunks) { + available.add(chunk) + } + if (availability.get(group)?.size !== available.size) { + availability.set(group, available) + changed = true + } + } + } + + for (const { group, chunks } of groups) { + if (!availability.has(group)) { + availability.set(group, new Set(chunks)) + } + } + + return availability +} + /** * Compute the emitted chunks reached by active static module dependencies. * Dependencies nested in async blocks are deliberately excluded: those are @@ -218,6 +292,7 @@ function computeDynamicImports(chunk: RspackCompilationChunk): Array { */ function computeStaticDependencyChunks( compilation: RspackCompilation, + groupAvailability: ChunkGroupAvailability, chunk: RspackCompilationChunk, ): Array { const importedChunks: Array = [] @@ -227,18 +302,15 @@ function computeStaticDependencyChunks( ) const localModules = new Set(modules) const runtime = Array.from(chunk.runtime) - // A shared chunk can belong to several route groups. Group membership - // limits where dependencies can be loaded from; it does not establish - // an import from the shared chunk back to every route in those groups. - const availableChunks = new Set() - const groups = new Set(chunk.groupsIterable) - for (const group of groups) { - for (const availableChunk of group.chunks) { - availableChunks.add(availableChunk) - } - for (const parent of group.getParents()) { - groups.add(parent) - } + // A module can be copied into a different chunk for each loading context of + // a shared chunk, so only chunks loaded in every one of its groups count. + let availableChunks: ReadonlySet | undefined + for (const group of chunk.groupsIterable) { + const groupAvailable = + groupAvailability.get(group) ?? new Set() + availableChunks = availableChunks + ? intersect(availableChunks, groupAvailable) + : groupAvailable } for (const module of modules) { @@ -255,7 +327,7 @@ function computeStaticDependencyChunks( for (const importedChunk of compilation.chunkGraph.getModuleChunksIterable( importedModule, )) { - if (availableChunks.has(importedChunk) && !seen.has(importedChunk)) { + if (availableChunks?.has(importedChunk) && !seen.has(importedChunk)) { seen.add(importedChunk) importedChunks.push(importedChunk) } @@ -279,11 +351,16 @@ function createStaticDependencyChunkResolver( compilation: RspackCompilation, ): GetStaticDependencyChunks { const cache = new Map>() + const groupAvailability = computeChunkGroupAvailability(compilation) return (chunk) => { let importedChunks = cache.get(chunk) if (!importedChunks) { - importedChunks = computeStaticDependencyChunks(compilation, chunk) + importedChunks = computeStaticDependencyChunks( + compilation, + groupAvailability, + chunk, + ) cache.set(chunk, importedChunks) } return importedChunks diff --git a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts index f563c57bf7..904d148c39 100644 --- a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts @@ -124,6 +124,7 @@ function makeWindowsCompilation(readCss: () => string | Uint8Array) { const compilation = { entrypoints: new Map([['index', { chunks: [entryChunk] }]]), chunks: new Set([entryChunk, routeChunk]), + chunkGroups: [], moduleGraph: { getConnection: () => undefined }, chunkGraph: { getModuleChunksIterable: () => [], @@ -496,6 +497,259 @@ describe('normalizeRspackClientBuild', () => { ]) }) + test('does not import sibling route copies of a module from a shared chunk', async () => { + const routes = ['alpha', 'beta', 'gamma'] + const { clientBuild, directory } = await buildClientFixture( + { + 'index.js': `globalThis.routes = [${routes + .map( + (route) => + `() => import(/* webpackChunkName: "${route}" */ './${route}.js?tsr-split=component')`, + ) + .join(', ')}]`, + ...Object.fromEntries( + routes.flatMap((route) => [ + [ + `${route}.js`, + ` + import { shared } from './shared.js' + import './${route}.css' + export const render = () => shared() + '${route}' + `, + ], + [`${route}.css`, `.${route} { color: red; }`], + ]), + ), + 'shared.js': `import { widget } from './widget.js'; export const shared = () => widget()`, + 'widget.js': `import './widget.css'; export const widget = () => 'widget'`, + 'widget.css': '.widget { display: grid; }', + }, + { + optimization: { + splitChunks: { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + shared: { + name: 'shared', + test: /shared\.js$/, + chunks: 'async', + enforce: true, + }, + }, + }, + }, + }, + ) + const manifest = buildStartManifest({ + clientBuild, + basePath: '/', + routeTreeRoutes: { + __root__: { children: routes.map((route) => `/${route}`) }, + ...Object.fromEntries( + routes.map((route) => [ + `/${route}`, + { filePath: join(directory, `${route}.js`) }, + ]), + ), + }, + }) + + expect(clientBuild.chunksByFileName.get('shared.js')?.imports).toEqual([]) + for (const route of routes) { + expect(manifest.routes[`/${route}`]?.css).toEqual([`/${route}.css`]) + expect(manifest.routes[`/${route}`]?.preloads).toEqual([ + `/${route}.js`, + '/shared.js', + ]) + const routeCss = clientBuild.cssContentByFileName?.get(`${route}.css`) + expect(routeCss).toMatch(/\.widget\s*\{/) + expect(routeCss).toMatch(new RegExp(`\\.${route}\\s*\\{`)) + for (const otherRoute of routes.filter((other) => other !== route)) { + expect(routeCss).not.toMatch(new RegExp(`\\.${otherRoute}\\s*\\{`)) + } + } + }) + + test('does not import a chunk that only one parent of a lazy group supplies', async () => { + const { clientBuild } = await buildClientFixture( + { + 'index.js': ` + globalThis.routes = [ + () => import(/* webpackChunkName: "first" */ './first.js?tsr-split=component'), + () => import(/* webpackChunkName: "second" */ './second.js?tsr-split=component'), + ] + `, + 'first.js': ` + import { dep } from './dep.js' + export const render = () => dep() + 'first' + export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js') + `, + 'second.js': ` + import { dep } from './dep.js' + export const render = () => dep() + 'second' + export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js') + `, + 'lazy.js': `import { dep } from './dep.js'; export const lazy = () => dep()`, + 'dep.js': `export const dep = () => 'dep'`, + }, + { + optimization: { + splitChunks: { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + dep: { + name: 'dep', + test: /dep\.js$/, + chunks: (chunk) => chunk.name === 'first', + enforce: true, + }, + }, + }, + }, + }, + ) + + expect(clientBuild.chunksByFileName.get('first.js')?.imports).toEqual([ + 'dep.js', + ]) + expect(clientBuild.chunksByFileName.get('lazy.js')?.imports).toEqual([]) + }) + + test('imports a chunk that every parent of a lazy group supplies', async () => { + const { clientBuild } = await buildClientFixture( + { + 'index.js': ` + import { base } from './base.js' + globalThis.base = base + globalThis.routes = [ + () => import(/* webpackChunkName: "first" */ './first.js?tsr-split=component'), + () => import(/* webpackChunkName: "second" */ './second.js?tsr-split=component'), + ] + `, + 'first.js': ` + import { common } from './common.js' + export const render = () => common() + 'first' + export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js') + `, + 'second.js': ` + import { common } from './common.js' + export const render = () => common() + 'second' + export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js') + `, + 'lazy.js': ` + import { base } from './base.js' + import { common } from './common.js' + export const lazy = () => base() + common() + `, + 'base.js': `export const base = () => 'base'`, + 'common.js': `export const common = () => 'common'`, + }, + { + optimization: { + splitChunks: { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + common: { + name: 'common', + test: /common\.js$/, + chunks: 'async', + enforce: true, + }, + }, + }, + }, + }, + ) + + expect(clientBuild.chunksByFileName.get('lazy.js')?.imports).toEqual([ + 'index.js', + 'common.js', + ]) + }) + + test.each([ + { routeOrder: ['ping', 'pong'] }, + { routeOrder: ['pong', 'ping'] }, + ])( + 'resolves static imports in cyclic lazy routes regardless of order ($routeOrder)', + async ({ routeOrder }) => { + const otherRoute = { ping: 'pong', pong: 'ping' } as const + const { clientBuild, directory } = await buildClientFixture( + { + 'index.js': `globalThis.routes = [${routeOrder + .map( + (route) => + `() => import(/* webpackChunkName: "${route}" */ './${route}.js?tsr-split=component')`, + ) + .join(', ')}]`, + ...Object.fromEntries( + (['ping', 'pong'] as const).flatMap((route) => [ + [ + `${route}.js`, + ` + import { shared } from './shared.js' + import './${route}.css' + export const render = () => shared() + '${route}' + export const next = () => import(/* webpackChunkName: "${otherRoute[route]}" */ './${otherRoute[route]}.js?tsr-split=component') + `, + ], + [`${route}.css`, `.${route} { color: red; }`], + ]), + ), + 'shared.js': `import { widget } from './widget.js'; export const shared = () => widget()`, + 'widget.js': `import './widget.css'; export const widget = () => 'widget'`, + 'widget.css': '.widget { display: grid; }', + }, + { + optimization: { + splitChunks: { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + shared: { + name: 'shared', + test: /shared\.js$/, + chunks: 'async', + enforce: true, + }, + }, + }, + }, + }, + ) + const manifest = buildStartManifest({ + clientBuild, + basePath: '/', + routeTreeRoutes: { + __root__: { children: routeOrder.map((route) => `/${route}`) }, + ...Object.fromEntries( + routeOrder.map((route) => [ + `/${route}`, + { filePath: join(directory, `${route}.js`) }, + ]), + ), + }, + }) + + expect(clientBuild.chunksByFileName.get('shared.js')?.imports).toEqual( + [], + ) + for (const route of ['ping', 'pong'] as const) { + const chunk = clientBuild.chunksByFileName.get(`${route}.js`) + expect(chunk?.imports).toEqual(['shared.js']) + expect(chunk?.dynamicImports).toContain(`${otherRoute[route]}.js`) + expect(manifest.routes[`/${route}`]?.css).toEqual([`/${route}.css`]) + } + }, + ) + test('collects nested CSS imports before the importing route stylesheet', async () => { const { clientBuild, directory } = await buildClientFixture( { From a4796ed5ced67bd933e96a3a4b132bef0087286f Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Wed, 21 Oct 2026 11:51:52 -0700 Subject: [PATCH 2/4] fix(start-plugin-core): resolve Rsbuild static chunk imports in their own loading context --- .../rsbuild-static-chunk-availability.md | 2 +- .../src/rsbuild/normalized-client-build.ts | 162 ++++++++++++------ .../rsbuild/normalized-client-build.test.ts | 130 ++++++++++---- 3 files changed, 209 insertions(+), 85 deletions(-) diff --git a/.changeset/rsbuild-static-chunk-availability.md b/.changeset/rsbuild-static-chunk-availability.md index 92392438bc..cd918ed75a 100644 --- a/.changeset/rsbuild-static-chunk-availability.md +++ b/.changeset/rsbuild-static-chunk-availability.md @@ -2,4 +2,4 @@ '@tanstack/start-plugin-core': patch --- -Record an Rsbuild static chunk import only when the chunk is loaded on every path to the importing chunk, so a shared chunk no longer links one route's lazy CSS and preloads into its sibling routes. +Resolve Rsbuild static chunk imports in each chunk's own loading context. A shared chunk no longer links one route's CSS and preloads into its sibling routes, and each route still links the split chunks the shared chunk uses in that route. diff --git a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts index 74fdac548a..bc52d9f9b3 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -230,10 +230,8 @@ function intersect( } /** - * Compute the chunks loaded whenever each chunk group loads: the group's own - * chunks plus the intersection of its parents' availability. Rspack's code - * splitter applies the same rule to modules. Entrypoints supply only their own - * chunks. + * Chunks loaded whenever a group loads: its own plus the intersection of its + * parents', the rule Rspack's code splitter applies to modules. */ function computeChunkGroupAvailability( compilation: RspackCompilation, @@ -244,9 +242,8 @@ function computeChunkGroupAvailability( parents: group.isInitial() ? [] : group.getParents(), })) - // A group missing from `availability` is not yet known and constrains no - // child. Known sets only shrink, so cyclic async graphs converge to the same - // result in any visiting order. + // Unknown parents constrain nothing and known sets only shrink, so cycles + // converge to the same result in any visiting order. const availability: ChunkGroupAvailability = new Map() let changed = true while (changed) { @@ -276,42 +273,25 @@ function computeChunkGroupAvailability( } } - for (const { group, chunks } of groups) { - if (!availability.has(group)) { - availability.set(group, new Set(chunks)) - } - } - return availability } /** - * Compute the emitted chunks reached by active static module dependencies. - * Dependencies nested in async blocks are deliberately excluded: those are - * represented by chunk-group children in `dynamicImports` instead. + * Emitted chunks holding the modules a chunk statically imports, in import + * order. Dependencies nested in async blocks are deliberately excluded: those + * are represented by chunk-group children in `dynamicImports` instead. */ -function computeStaticDependencyChunks( +function computeStaticDependencyCandidates( compilation: RspackCompilation, - groupAvailability: ChunkGroupAvailability, chunk: RspackCompilationChunk, ): Array { - const importedChunks: Array = [] + const candidates: Array = [] const seen = new Set([chunk]) const modules = getModulesIncludingConcatenated( compilation.chunkGraph.getChunkModules(chunk), ) const localModules = new Set(modules) const runtime = Array.from(chunk.runtime) - // A module can be copied into a different chunk for each loading context of - // a shared chunk, so only chunks loaded in every one of its groups count. - let availableChunks: ReadonlySet | undefined - for (const group of chunk.groupsIterable) { - const groupAvailable = - groupAvailability.get(group) ?? new Set() - availableChunks = availableChunks - ? intersect(availableChunks, groupAvailable) - : groupAvailable - } for (const module of modules) { for (const dependency of module.dependencies) { @@ -327,44 +307,127 @@ function computeStaticDependencyChunks( for (const importedChunk of compilation.chunkGraph.getModuleChunksIterable( importedModule, )) { - if (availableChunks?.has(importedChunk) && !seen.has(importedChunk)) { + if (!seen.has(importedChunk)) { seen.add(importedChunk) - importedChunks.push(importedChunk) + candidates.push(importedChunk) } } } } - return importedChunks + return candidates } type GetStaticDependencyChunks = ( chunk: RspackCompilationChunk, ) => Array +type StaticDependencyChunkResolver = { + getAvailableChunks: ( + chunk: RspackCompilationChunk, + ) => ReadonlySet + getCandidates: GetStaticDependencyChunks + getStaticDependencyChunks: GetStaticDependencyChunks +} + /** - * Memoize static dependency resolution per chunk. Every chunk is resolved once - * for its own `imports`, and CSS-only chunks are resolved again from each - * importer that reaches them. + * Memoize dependency discovery per chunk. A chunk's static dependency chunks + * are the candidates loaded in every one of its groups. */ function createStaticDependencyChunkResolver( compilation: RspackCompilation, -): GetStaticDependencyChunks { - const cache = new Map>() +): StaticDependencyChunkResolver { const groupAvailability = computeChunkGroupAvailability(compilation) + const candidatesByChunk = new Map< + RspackCompilationChunk, + Array + >() + const availableByChunk = new Map< + RspackCompilationChunk, + ReadonlySet + >() + const dependenciesByChunk = new Map< + RspackCompilationChunk, + Array + >() + + const getCandidates = (chunk: RspackCompilationChunk) => { + let candidates = candidatesByChunk.get(chunk) + if (!candidates) { + candidates = computeStaticDependencyCandidates(compilation, chunk) + candidatesByChunk.set(chunk, candidates) + } + return candidates + } - return (chunk) => { - let importedChunks = cache.get(chunk) - if (!importedChunks) { - importedChunks = computeStaticDependencyChunks( - compilation, - groupAvailability, - chunk, + // Rspack may copy a module into each loading context, so require every group. + const getAvailableChunks = (chunk: RspackCompilationChunk) => { + let available = availableByChunk.get(chunk) + if (!available) { + for (const group of chunk.groupsIterable) { + const groupAvailable = + groupAvailability.get(group) ?? new Set() + available = available + ? intersect(available, groupAvailable) + : groupAvailable + } + available ??= new Set() + availableByChunk.set(chunk, available) + } + return available + } + + const getStaticDependencyChunks = (chunk: RspackCompilationChunk) => { + let dependencies = dependenciesByChunk.get(chunk) + if (!dependencies) { + const available = getAvailableChunks(chunk) + dependencies = getCandidates(chunk).filter((candidate) => + available.has(candidate), ) - cache.set(chunk, importedChunks) + dependenciesByChunk.set(chunk, dependencies) } - return importedChunks + return dependencies } + + return { getAvailableChunks, getCandidates, getStaticDependencyChunks } +} + +/** + * A dependency's own `imports` hold only what all its contexts load. When this + * chunk's context supplies more, list its whole closure dependencies first. + */ +function computeImportedChunks( + resolver: StaticDependencyChunkResolver, + chunk: RspackCompilationChunk, +): Array { + const reached = new Set([chunk]) + const reach = (current: RspackCompilationChunk) => { + for (const dependency of resolver.getStaticDependencyChunks(current)) { + if (!reached.has(dependency)) { + reached.add(dependency) + reach(dependency) + } + } + } + reach(chunk) + + const available = resolver.getAvailableChunks(chunk) + const visited = new Set([chunk]) + const dependenciesFirst: Array = [] + const visit = (current: RspackCompilationChunk) => { + for (const candidate of resolver.getCandidates(current)) { + if (available.has(candidate) && !visited.has(candidate)) { + visited.add(candidate) + visit(candidate) + dependenciesFirst.push(candidate) + } + } + } + visit(chunk) + + return dependenciesFirst.some((dependency) => !reached.has(dependency)) + ? dependenciesFirst + : resolver.getStaticDependencyChunks(chunk) } /** @@ -434,8 +497,7 @@ export function normalizeRspackClientBuild( } } - const getStaticDependencyChunks = - createStaticDependencyChunkResolver(compilation) + const resolver = createStaticDependencyChunkResolver(compilation) // Iterate ALL chunks (initial + async) to capture route-split chunks for (const chunk of compilation.chunks) { @@ -454,9 +516,9 @@ export function normalizeRspackClientBuild( // Compute dynamicImports from chunk group children const dynamicImports = computeDynamicImports(chunk) - const staticDependencyChunks = getStaticDependencyChunks(chunk) + const staticDependencyChunks = computeImportedChunks(resolver, chunk) const dependencyCssFiles = computeCssOnlyDependencyFiles( - getStaticDependencyChunks, + resolver.getStaticDependencyChunks, staticDependencyChunks, ).filter((cssFile) => !cssFiles.includes(cssFile)) if (dependencyCssFiles.length > 0) { diff --git a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts index 904d148c39..65e1682170 100644 --- a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts @@ -563,15 +563,96 @@ describe('normalizeRspackClientBuild', () => { `/${route}.js`, '/shared.js', ]) - const routeCss = clientBuild.cssContentByFileName?.get(`${route}.css`) - expect(routeCss).toMatch(/\.widget\s*\{/) - expect(routeCss).toMatch(new RegExp(`\\.${route}\\s*\\{`)) - for (const otherRoute of routes.filter((other) => other !== route)) { - expect(routeCss).not.toMatch(new RegExp(`\\.${otherRoute}\\s*\\{`)) - } + expect( + clientBuild.cssContentByFileName?.get(`${route}.css`), + ).toMatch(/\.widget\s*\{/) } }) + test('links the split chunk a shared chunk uses in each route that loads it', async () => { + const routes = ['alpha', 'beta', 'gamma'] + const { clientBuild, directory } = await buildClientFixture( + { + 'index.js': `globalThis.routes = [${routes + .map( + (route) => + `() => import(/* webpackChunkName: "${route}" */ './${route}.js?tsr-split=component')`, + ) + .join(', ')}]`, + ...Object.fromEntries( + routes.flatMap((route) => [ + [ + `${route}.js`, + ` + import { shared } from './shared.js' + import './${route}.css' + export const render = () => shared() + '${route}' + `, + ], + [`${route}.css`, `.${route} { color: red; }`], + ]), + ), + 'shared.js': `import { widget } from './widget.js'; export const shared = () => widget()`, + 'widget.js': `import './widget.css'; export const widget = () => 'widget'`, + 'widget.css': '.widget { display: grid; }', + }, + { + optimization: { + splitChunks: { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + shared: { + name: 'shared', + test: /shared\.js$/, + chunks: 'async', + enforce: true, + }, + widget: { + name: 'widget', + test: /widget\.(?:js|css)$/, + chunks: (chunk) => chunk.name !== 'gamma', + enforce: true, + }, + }, + }, + }, + }, + ) + const manifest = buildStartManifest({ + clientBuild, + basePath: '/', + routeTreeRoutes: { + __root__: { children: routes.map((route) => `/${route}`) }, + ...Object.fromEntries( + routes.map((route) => [ + `/${route}`, + { filePath: join(directory, `${route}.js`) }, + ]), + ), + }, + }) + + expect(clientBuild.chunksByFileName.get('shared.js')?.imports).toEqual([]) + for (const route of ['alpha', 'beta']) { + expect(manifest.routes[`/${route}`]?.css).toEqual([ + '/widget.css', + `/${route}.css`, + ]) + expect(manifest.routes[`/${route}`]?.preloads).toEqual([ + `/${route}.js`, + '/widget.js', + '/shared.js', + ]) + } + expect(manifest.routes['/gamma']?.css).toEqual(['/gamma.css']) + expect(manifest.routes['/gamma']?.preloads).toEqual([ + '/gamma.js', + '/shared.js', + ]) + }) + test('does not import a chunk that only one parent of a lazy group supplies', async () => { const { clientBuild } = await buildClientFixture( { @@ -680,7 +761,7 @@ describe('normalizeRspackClientBuild', () => { 'resolves static imports in cyclic lazy routes regardless of order ($routeOrder)', async ({ routeOrder }) => { const otherRoute = { ping: 'pong', pong: 'ping' } as const - const { clientBuild, directory } = await buildClientFixture( + const { clientBuild } = await buildClientFixture( { 'index.js': `globalThis.routes = [${routeOrder .map( @@ -689,22 +770,17 @@ describe('normalizeRspackClientBuild', () => { ) .join(', ')}]`, ...Object.fromEntries( - (['ping', 'pong'] as const).flatMap((route) => [ - [ - `${route}.js`, - ` - import { shared } from './shared.js' - import './${route}.css' - export const render = () => shared() + '${route}' - export const next = () => import(/* webpackChunkName: "${otherRoute[route]}" */ './${otherRoute[route]}.js?tsr-split=component') - `, - ], - [`${route}.css`, `.${route} { color: red; }`], + (['ping', 'pong'] as const).map((route) => [ + `${route}.js`, + ` + import { shared } from './shared.js' + export const render = () => shared() + '${route}' + export const next = () => import(/* webpackChunkName: "${otherRoute[route]}" */ './${otherRoute[route]}.js?tsr-split=component') + `, ]), ), 'shared.js': `import { widget } from './widget.js'; export const shared = () => widget()`, - 'widget.js': `import './widget.css'; export const widget = () => 'widget'`, - 'widget.css': '.widget { display: grid; }', + 'widget.js': `export const widget = () => 'widget'`, }, { optimization: { @@ -724,19 +800,6 @@ describe('normalizeRspackClientBuild', () => { }, }, ) - const manifest = buildStartManifest({ - clientBuild, - basePath: '/', - routeTreeRoutes: { - __root__: { children: routeOrder.map((route) => `/${route}`) }, - ...Object.fromEntries( - routeOrder.map((route) => [ - `/${route}`, - { filePath: join(directory, `${route}.js`) }, - ]), - ), - }, - }) expect(clientBuild.chunksByFileName.get('shared.js')?.imports).toEqual( [], @@ -745,7 +808,6 @@ describe('normalizeRspackClientBuild', () => { const chunk = clientBuild.chunksByFileName.get(`${route}.js`) expect(chunk?.imports).toEqual(['shared.js']) expect(chunk?.dynamicImports).toContain(`${otherRoute[route]}.js`) - expect(manifest.routes[`/${route}`]?.css).toEqual([`/${route}.css`]) } }, ) From 3e09e471e9fae53e73c41f073fbf5ced5b5a6e13 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:54:46 +0000 Subject: [PATCH 3/4] ci: apply automated fixes --- .../tests/rsbuild/normalized-client-build.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts index 65e1682170..f3e8c4c5f7 100644 --- a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts @@ -563,9 +563,9 @@ describe('normalizeRspackClientBuild', () => { `/${route}.js`, '/shared.js', ]) - expect( - clientBuild.cssContentByFileName?.get(`${route}.css`), - ).toMatch(/\.widget\s*\{/) + expect(clientBuild.cssContentByFileName?.get(`${route}.css`)).toMatch( + /\.widget\s*\{/, + ) } }) @@ -801,9 +801,7 @@ describe('normalizeRspackClientBuild', () => { }, ) - expect(clientBuild.chunksByFileName.get('shared.js')?.imports).toEqual( - [], - ) + expect(clientBuild.chunksByFileName.get('shared.js')?.imports).toEqual([]) for (const route of ['ping', 'pong'] as const) { const chunk = clientBuild.chunksByFileName.get(`${route}.js`) expect(chunk?.imports).toEqual(['shared.js']) From 454207874a24bcefa104f8748e9ccc71f6375913 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 29 Oct 2026 12:02:45 -0700 Subject: [PATCH 4/4] fix(start): collect Rsbuild assets by route loading context Collect JavaScript and CSS from route chunk groups instead of reconstructing global chunk imports. Preserve parent-path intersection and the Vite chunk-based manifest path. Include entry-owned hydration styles so root-level deferred content is styled without loading its JavaScript. Cover shared loading groups and root hydration with compiler and production browser regressions. --- .../deferred-hydration/src/routes/__root.tsx | 11 + .../src/shared/root-deferred.module.css | 3 + .../tests/hydration.spec.ts | 9 +- .../src/rsbuild/normalized-client-build.ts | 713 +++++------------- .../start-manifest-plugin/manifestBuilder.ts | 167 +++- packages/start-plugin-core/src/types.ts | 21 +- .../normalized-client-build.ts | 7 +- .../rsbuild/normalized-client-build.test.ts | 364 +++++++-- 8 files changed, 659 insertions(+), 636 deletions(-) create mode 100644 e2e/react-start/deferred-hydration/src/shared/root-deferred.module.css diff --git a/e2e/react-start/deferred-hydration/src/routes/__root.tsx b/e2e/react-start/deferred-hydration/src/routes/__root.tsx index bfdc646fc5..4f9e6a4bf4 100644 --- a/e2e/react-start/deferred-hydration/src/routes/__root.tsx +++ b/e2e/react-start/deferred-hydration/src/routes/__root.tsx @@ -7,6 +7,9 @@ import { Scripts, createRootRoute, } from '@tanstack/react-router' +import { Hydrate } from '@tanstack/react-start' +import { never } from '@tanstack/react-start/hydration' +import rootDeferredStyles from '../shared/root-deferred.module.css' export const Route = createRootRoute({ head: () => ({ @@ -173,6 +176,14 @@ function RootDocument({ children }: { children: React.ReactNode }) { Scroll {children} + +
+ Root deferred CSS +
+
diff --git a/e2e/react-start/deferred-hydration/src/shared/root-deferred.module.css b/e2e/react-start/deferred-hydration/src/shared/root-deferred.module.css new file mode 100644 index 0000000000..11b1a7874a --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/shared/root-deferred.module.css @@ -0,0 +1,3 @@ +.panel { + color: rgb(21, 43, 65); +} diff --git a/e2e/react-start/deferred-hydration/tests/hydration.spec.ts b/e2e/react-start/deferred-hydration/tests/hydration.spec.ts index bd097d2ead..050d50ba6b 100644 --- a/e2e/react-start/deferred-hydration/tests/hydration.spec.ts +++ b/e2e/react-start/deferred-hydration/tests/hydration.spec.ts @@ -845,7 +845,7 @@ test.describe('Hydrate CSS delivery', () => { 'production hydration coverage runs against the preview server', ) - test('ships CSS for deferred, never, shared, and nested boundaries without JavaScript', async ({ + test('ships CSS for root, deferred, never, shared, and nested boundaries without JavaScript', async ({ browser, request, }) => { @@ -857,6 +857,7 @@ test.describe('Hydrate CSS delivery', () => { htmlContainsText(html, 'Deferred CSS') htmlContainsText(html, 'Never CSS') htmlContainsText(html, 'Nested CSS') + htmlContainsText(html, 'Root deferred CSS') const context = await browser.newContext({ javaScriptEnabled: false }) const page = await context.newPage() @@ -871,6 +872,12 @@ test.describe('Hydrate CSS delivery', () => { await expect(page.getByTestId('css-never')).toHaveText('Never CSS') await expect(page.getByTestId('css-nested')).toHaveText('Nested CSS') + await expectCssProperty( + page, + 'css-root-never', + 'color', + 'rgb(21, 43, 65)', + ) await expectCssProperty(page, 'css-outer', 'color', 'rgb(12, 34, 56)') await expectCssProperty( page, diff --git a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts index bc52d9f9b3..351893c2fe 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -5,604 +5,283 @@ import { RSBUILD_ENVIRONMENT_NAMES } from './planning' import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' import type { GetConfigFn, - NormalizedClientBuild, - NormalizedClientChunk, + NormalizedClientAssets, + NormalizedClientGroupBuild, } from '../types' type ProcessAssetsContext = Parameters< Parameters[1] >[0] -type RspackCompilation = Rspack.Compilation -type RspackCompilationChunk = Rspack.Chunk -type RspackChunkGroup = Rspack.ChunkGroup -type RspackModule = Rspack.Module -type ChunkGroupAvailability = Map< - RspackChunkGroup, - ReadonlySet -> +type ChunkGroup = Rspack.ChunkGroup -const backslashRegex = /\\/g - -/** - * Convert an OS native path to the POSIX form used by the generated route tree. - */ -function toPosixPath(filePath: string): string { - return filePath.replace(backslashRegex, '/') -} - -/** - * Extract route file paths from rspack module identifiers. - * - * In rspack, module identifiers contain query params similar to Vite's moduleIds. - * We look for the `tsr-split` query to identify route-split chunks. - */ -function getRouteFilePathsFromModules( - modules: Array, -): Array { - let routeFilePaths: Array | undefined - let seen: Set | undefined - - for (const mod of modules) { - const identifier = mod.identifier() - - // rspack module identifiers include loader prefixes separated by '!'. - // The actual file path (with query string) is after the last '!'. - // Example: "builtin:swc-loader??ruleSet[...]!.../transform.js??...!.../rsc-basic.tsx?tsr-split=component" - const lastBangIndex = identifier.lastIndexOf('!') - const resourcePart = - lastBangIndex >= 0 ? identifier.slice(lastBangIndex + 1) : identifier - - const queryIndex = resourcePart.indexOf('?') - if (queryIndex < 0) continue - - const query = resourcePart.slice(queryIndex + 1) - if (!query.includes(tsrSplit)) continue - if (!new URLSearchParams(query).has(tsrSplit)) continue - - const nameForCondition = mod.nameForCondition() - - // rspack reports module paths using the OS separator, while the generated route - // tree records every route `filePath` with POSIX separators. Normalize before the - // path becomes a manifest key, otherwise no route matches its chunk on Windows and - // every route loses its stylesheets and preloads. - const routeFilePath = toPosixPath( - nameForCondition ?? resourcePart.slice(0, queryIndex), - ) - - if (seen?.has(routeFilePath)) continue - - if (!routeFilePaths || !seen) { - routeFilePaths = [] - seen = new Set() - } - - routeFilePaths.push(routeFilePath) - seen.add(routeFilePath) - } - - return routeFilePaths ?? [] -} - -function getHydrationIdsFromModules( - modules: Array, -): Array { - let hydrationIds: Array | undefined - let seen: Set | undefined - - for (const mod of modules) { - const identifier = mod.identifier() - const lastBangIndex = identifier.lastIndexOf('!') - const resourcePart = - lastBangIndex >= 0 ? identifier.slice(lastBangIndex + 1) : identifier - - const queryIndex = resourcePart.indexOf('?') - if (queryIndex < 0) continue - - const query = resourcePart.slice(queryIndex + 1) - if (!query.includes(tssHydrate)) continue - - const hydrationId = new URLSearchParams(query).get(tssHydrate) - if (!hydrationId || seen?.has(hydrationId)) continue - - if (!hydrationIds || !seen) { - hydrationIds = [] - seen = new Set() - } - - hydrationIds.push(hydrationId) - seen.add(hydrationId) - } - - return hydrationIds ?? [] -} - -/** - * Returns true for Rspack/webpack HMR runtime chunks that should never be - * surfaced to the Start manifest. These files are emitted on every rebuild - * (e.g. `index..hot-update.mjs`) and must not be treated as the entry - * chunk, route preloads, or sibling imports. - */ -function isHotUpdateAsset(file: string): boolean { - return file.includes('.hot-update.') -} - -/** - * True for any JS/MJS asset that should be included in the manifest. - * Excludes HMR runtime patches. - */ function isManifestJsAsset(file: string): boolean { - if (!file.endsWith('.js') && !file.endsWith('.mjs')) return false - return !isHotUpdateAsset(file) -} - -/** - * Get all JS file names from a chunk. - */ -function getChunkJsFiles(chunk: RspackCompilationChunk): Array { - const jsFiles: Array = [] - for (const file of chunk.files) { - if (isManifestJsAsset(file)) { - jsFiles.push(file) - } - } - return jsFiles + return ( + (file.endsWith('.js') || file.endsWith('.mjs')) && + !file.includes('.hot-update.') + ) } -/** - * Get unique CSS file names from both primary and auxiliary chunk assets. - */ -function getChunkCssFiles(chunk: RspackCompilationChunk): Array { - const cssFiles: Array = [] - const seen = new Set() - - for (const file of [...chunk.auxiliaryFiles, ...chunk.files]) { - if (file.endsWith('.css') && !seen.has(file)) { - seen.add(file) - cssFiles.push(file) - } - } - - return cssFiles +function getRequestQuery(request: string): URLSearchParams { + const resource = request.slice(request.lastIndexOf('!') + 1) + const queryIndex = resource.indexOf('?') + return new URLSearchParams( + queryIndex < 0 ? '' : resource.slice(queryIndex + 1).split('#')[0], + ) } -/** - * Include inner modules so concatenation does not hide route metadata or - * static dependency edges. - */ -function getModulesIncludingConcatenated( - modules: Array, -): Array { - const allModules = new Set(modules) - for (const module of allModules) { - const concatenatedModules = ( - module as RspackModule & { modules?: Array } - ).modules - if (concatenatedModules) { - for (const concatenatedModule of concatenatedModules) { - allModules.add(concatenatedModule) +/** Resolve tagged imports once per importer, including aliases and relative paths. */ +function getTaggedImportPaths( + compilation: Rspack.Compilation, + module: Rspack.Module, +): Map { + const paths = new Map() + const blocks = [...module.blocks] + for (const block of blocks) { + blocks.push(...block.blocks) + for (const dependency of block.dependencies) { + const request = dependency.request + if (!request || !getRequestQuery(request).has(tsrSplit)) { + continue } - } - } - - return Array.from(allModules) -} - -/** - * Compute dynamicImports for a chunk by traversing its chunk groups' - * childrenIterable (async/dynamic import edges). - * - * In rspack, a chunk belongs to one or more ChunkGroups. Each ChunkGroup - * has childrenIterable — child ChunkGroups representing dynamic import() - * points. The JS files from those child groups' chunks are the - * dynamicImports (analogous to Rollup's OutputChunk.dynamicImports). - */ -function computeDynamicImports(chunk: RspackCompilationChunk): Array { - const dynamicImportFiles: Array = [] - const seen = new Set() - - for (const group of chunk.groupsIterable) { - for (const childGroup of group.childrenIterable) { - for (const childChunk of childGroup.chunks) { - for (const file of childChunk.files) { - if (isManifestJsAsset(file) && !seen.has(file)) { - seen.add(file) - dynamicImportFiles.push(file) - } - } + const target = compilation.moduleGraph.getModule(dependency) + const filePath = target?.nameForCondition() + if (filePath) { + paths.set(request, filePath.replace(/\\/g, '/')) } } } - - return dynamicImportFiles -} - -function intersect( - left: ReadonlySet, - right: ReadonlySet, -): ReadonlySet { - const result = new Set() - for (const value of left) { - if (right.has(value)) { - result.add(value) - } - } - return result + return paths } -/** - * Chunks loaded whenever a group loads: its own plus the intersection of its - * parents', the rule Rspack's code splitter applies to modules. - */ -function computeChunkGroupAvailability( - compilation: RspackCompilation, -): ChunkGroupAvailability { - const groups = compilation.chunkGroups.map((group) => ({ +/** A parent contributes only chunks present on every path into the group. */ +function getAvailableChunks(groups: ReadonlyArray) { + const available = new Map>() + const records = groups.map((group) => ({ group, chunks: group.chunks, parents: group.isInitial() ? [] : group.getParents(), })) - - // Unknown parents constrain nothing and known sets only shrink, so cycles - // converge to the same result in any visiting order. - const availability: ChunkGroupAvailability = new Map() let changed = true while (changed) { changed = false - for (const { group, chunks, parents } of groups) { - let inherited: ReadonlySet | undefined + for (const { group, chunks, parents } of records) { + let inherited: Set | undefined for (const parent of parents) { - const parentAvailable = availability.get(parent) - if (parentAvailable) { - inherited = inherited - ? intersect(inherited, parentAvailable) - : parentAvailable + const parentChunks = available.get(parent) + if (!parentChunks) { + continue } + inherited = inherited + ? new Set([...inherited].filter((chunk) => parentChunks.has(chunk))) + : new Set(parentChunks) } if (parents.length > 0 && !inherited) { continue } - - const available = new Set(inherited) + const next = inherited ?? new Set() for (const chunk of chunks) { - available.add(chunk) + next.add(chunk) } - if (availability.get(group)?.size !== available.size) { - availability.set(group, available) + // Starting at the entry groups, known sets shrink as parent paths + // resolve. Revisit until cyclic async graphs converge in either order. + if (available.get(group)?.size !== next.size) { + available.set(group, next) changed = true } } } - - return availability + return available } -/** - * Emitted chunks holding the modules a chunk statically imports, in import - * order. Dependencies nested in async blocks are deliberately excluded: those - * are represented by chunk-group children in `dynamicImports` instead. - */ -function computeStaticDependencyCandidates( - compilation: RspackCompilation, - chunk: RspackCompilationChunk, -): Array { - const candidates: Array = [] - const seen = new Set([chunk]) - const modules = getModulesIncludingConcatenated( - compilation.chunkGraph.getChunkModules(chunk), - ) - const localModules = new Set(modules) - const runtime = Array.from(chunk.runtime) - - for (const module of modules) { - for (const dependency of module.dependencies) { - const connection = compilation.moduleGraph.getConnection(dependency) - const importedModule = connection?.module - if (!connection || !importedModule || localModules.has(importedModule)) { - continue - } - if (connection.getActiveState(runtime) === false) { - continue - } - - for (const importedChunk of compilation.chunkGraph.getModuleChunksIterable( - importedModule, - )) { - if (!seen.has(importedChunk)) { - seen.add(importedChunk) - candidates.push(importedChunk) - } +function appendAssets( + target: NormalizedClientAssets, + source: NormalizedClientAssets, +) { + for (const key of ['preloads', 'css'] as const) { + const seen = new Set(target[key]) + for (const file of source[key]) { + if (!seen.has(file)) { + target[key].push(file) + seen.add(file) } } } - - return candidates } -type GetStaticDependencyChunks = ( - chunk: RspackCompilationChunk, -) => Array - -type StaticDependencyChunkResolver = { - getAvailableChunks: ( - chunk: RspackCompilationChunk, - ) => ReadonlySet - getCandidates: GetStaticDependencyChunks - getStaticDependencyChunks: GetStaticDependencyChunks -} - -/** - * Memoize dependency discovery per chunk. A chunk's static dependency chunks - * are the candidates loaded in every one of its groups. - */ -function createStaticDependencyChunkResolver( - compilation: RspackCompilation, -): StaticDependencyChunkResolver { - const groupAvailability = computeChunkGroupAvailability(compilation) - const candidatesByChunk = new Map< - RspackCompilationChunk, - Array - >() - const availableByChunk = new Map< - RspackCompilationChunk, - ReadonlySet - >() - const dependenciesByChunk = new Map< - RspackCompilationChunk, - Array - >() - - const getCandidates = (chunk: RspackCompilationChunk) => { - let candidates = candidatesByChunk.get(chunk) - if (!candidates) { - candidates = computeStaticDependencyCandidates(compilation, chunk) - candidatesByChunk.set(chunk, candidates) - } - return candidates +/** Capture route loading contexts without manufacturing chunk import edges. */ +export function normalizeRspackClientBuild( + compilation: Rspack.Compilation, + inlineCssEnabled = false, +): NormalizedClientGroupBuild { + const entrypoint = compilation.entrypoints.get('index') + const entryChunk = entrypoint?.getEntrypointChunk() + const entryChunkFileName = + entryChunk && [...entryChunk.files].find(isManifestJsAsset) + if (!entrypoint || !entryChunkFileName) { + throw new Error('No entry file found in rspack client build') } - // Rspack may copy a module into each loading context, so require every group. - const getAvailableChunks = (chunk: RspackCompilationChunk) => { - let available = availableByChunk.get(chunk) - if (!available) { - for (const group of chunk.groupsIterable) { - const groupAvailable = - groupAvailability.get(group) ?? new Set() - available = available - ? intersect(available, groupAvailable) - : groupAvailable + const importsByModule = new Map>() + const groupsByRoute = new Map>() + const hydrationGroups = new Set() + for (const group of compilation.chunkGroups) { + for (const origin of group.origins) { + if (!origin.request) { + continue } - available ??= new Set() - availableByChunk.set(chunk, available) - } - return available - } - - const getStaticDependencyChunks = (chunk: RspackCompilationChunk) => { - let dependencies = dependenciesByChunk.get(chunk) - if (!dependencies) { - const available = getAvailableChunks(chunk) - dependencies = getCandidates(chunk).filter((candidate) => - available.has(candidate), - ) - dependenciesByChunk.set(chunk, dependencies) - } - return dependencies - } - - return { getAvailableChunks, getCandidates, getStaticDependencyChunks } -} - -/** - * A dependency's own `imports` hold only what all its contexts load. When this - * chunk's context supplies more, list its whole closure dependencies first. - */ -function computeImportedChunks( - resolver: StaticDependencyChunkResolver, - chunk: RspackCompilationChunk, -): Array { - const reached = new Set([chunk]) - const reach = (current: RspackCompilationChunk) => { - for (const dependency of resolver.getStaticDependencyChunks(current)) { - if (!reached.has(dependency)) { - reached.add(dependency) - reach(dependency) + const query = getRequestQuery(origin.request) + if (query.get(tssHydrate)) { + hydrationGroups.add(group) } - } - } - reach(chunk) - - const available = resolver.getAvailableChunks(chunk) - const visited = new Set([chunk]) - const dependenciesFirst: Array = [] - const visit = (current: RspackCompilationChunk) => { - for (const candidate of resolver.getCandidates(current)) { - if (available.has(candidate) && !visited.has(candidate)) { - visited.add(candidate) - visit(candidate) - dependenciesFirst.push(candidate) + if (!query.has(tsrSplit) || !origin.module) { + continue } - } - } - visit(chunk) - - return dependenciesFirst.some((dependency) => !reached.has(dependency)) - ? dependenciesFirst - : resolver.getStaticDependencyChunks(chunk) -} - -/** - * Collect CSS from dependency chunks that have no JavaScript asset of their - * own, preserving dependency-before-importer order across nested CSS chunks. - */ -function computeCssOnlyDependencyFiles( - getStaticDependencyChunks: GetStaticDependencyChunks, - chunks: Array, -): Array { - const cssFiles: Array = [] - const seenChunks = new Set() - const seenFiles = new Set() - - const visit = (chunk: RspackCompilationChunk) => { - if (seenChunks.has(chunk) || getChunkJsFiles(chunk).length > 0) { - return - } - seenChunks.add(chunk) - - for (const importedChunk of getStaticDependencyChunks(chunk)) { - visit(importedChunk) - } - - for (const cssFile of getChunkCssFiles(chunk)) { - if (!seenFiles.has(cssFile)) { - seenFiles.add(cssFile) - cssFiles.push(cssFile) + let imports = importsByModule.get(origin.module) + if (!imports) { + imports = getTaggedImportPaths(compilation, origin.module) + importsByModule.set(origin.module, imports) + } + const filePath = imports.get(origin.request) + if (!filePath) { + continue + } + let groups = groupsByRoute.get(filePath) + if (!groups) { + groups = new Set() + groupsByRoute.set(filePath, groups) } + groups.add(group) } } - for (const chunk of chunks) { - visit(chunk) - } - - return cssFiles -} - -/** - * Normalize an rspack compilation into a NormalizedClientBuild. - * - * Iterates ALL chunks in the compilation (initial + async), not just - * entrypoint chunks, to ensure route-split async chunks are included. - */ -export function normalizeRspackClientBuild( - compilation: RspackCompilation, - inlineCssEnabled = false, -): NormalizedClientBuild { - const chunksByFileName = new Map() - let cssContentByFileName: Map | undefined - let entryChunkFileName: string | undefined - - // Collect all initial JS file names from the main entry for computing - // the entry chunk's `imports` (vendor/shared sibling chunks). - const entrypoint = compilation.entrypoints.get('index') - const initialJsFileNames: Array = [] - const entryChunkSet = new Set() - if (entrypoint) { - for (const chunk of entrypoint.chunks) { - entryChunkSet.add(chunk) + const available = getAvailableChunks(compilation.chunkGroups) + const filesByChunk = new Map() + const cssModulesByChunk = new Map>() + const readChunk = (chunk: Rspack.Chunk) => { + let assets = filesByChunk.get(chunk) + if (!assets) { + assets = { preloads: [], css: [] } + const files = new Set([...chunk.files, ...chunk.auxiliaryFiles]) + for (const file of files) { + if (file.endsWith('.css') && !file.includes('.hot-update.')) { + assets.css.push(file) + } + } for (const file of chunk.files) { if (isManifestJsAsset(file)) { - initialJsFileNames.push(file) + assets.preloads.push(file) } } + filesByChunk.set(chunk, assets) } + return assets } - - const resolver = createStaticDependencyChunkResolver(compilation) - - // Iterate ALL chunks (initial + async) to capture route-split chunks - for (const chunk of compilation.chunks) { - const modules = getModulesIncludingConcatenated( - compilation.chunkGraph.getChunkModules(chunk), + const initialChunks = new Set(entrypoint.chunks) + const readGroup = (group: ChunkGroup): NormalizedClientAssets => { + const ownChunks = group.chunks + // Initial resources are merged into the root separately. Keep shared + // chunks that also belong to this group, but do not prepend entry CSS. + const chunks = [...(available.get(group) ?? ownChunks)].filter( + (chunk) => !initialChunks.has(chunk) || ownChunks.includes(chunk), ) - const routeFilePaths = getRouteFilePathsFromModules(modules) - const hydrationIds = getHydrationIdsFromModules(modules) - const cssFiles = getChunkCssFiles(chunk) - - // The entry chunk is the one named 'index' in the 'index' entrypoint - const isEntryChunk = chunk.name === 'index' && entryChunkSet.has(chunk) - - const jsFiles = getChunkJsFiles(chunk) - if (jsFiles.length === 0) continue - - // Compute dynamicImports from chunk group children - const dynamicImports = computeDynamicImports(chunk) - const staticDependencyChunks = computeImportedChunks(resolver, chunk) - const dependencyCssFiles = computeCssOnlyDependencyFiles( - resolver.getStaticDependencyChunks, - staticDependencyChunks, - ).filter((cssFile) => !cssFiles.includes(cssFile)) - if (dependencyCssFiles.length > 0) { - cssFiles.unshift(...dependencyCssFiles) - } - - for (const file of jsFiles) { - // For the entry chunk, `imports` contains all sibling initial chunks - // (vendor/shared). For other chunks, it contains the emitted chunks - // reached through active static module dependencies. - const imports = isEntryChunk - ? initialJsFileNames.filter((f) => f !== file) - : staticDependencyChunks.flatMap(getChunkJsFiles) - - const normalizedChunk: NormalizedClientChunk = { - fileName: file, - isEntry: isEntryChunk, - imports, - dynamicImports, - css: [], - routeFilePaths, - hydrationIds, + const preloads = new Set() + // Initial chunks must retain their execution order for IIFE scripts. + // Async groups put the route chunk last, so list it first for preloading. + const preloadChunks = group.isInitial() + ? ownChunks + : [...ownChunks].reverse() + for (const chunk of preloadChunks.concat(chunks)) { + for (const file of readChunk(chunk).preloads) { + preloads.add(file) } + } + const cssChunks = chunks.filter((chunk) => readChunk(chunk).css.length > 0) + const ordered = cssChunks + .map((chunk) => { + let modules = cssModulesByChunk.get(chunk) + if (!modules) { + modules = [ + ...compilation.chunkGraph.getChunkModulesIterableBySourceType( + chunk, + 'css', + ), + ...compilation.chunkGraph.getChunkModulesIterableBySourceType( + chunk, + 'css/mini-extract', + ), + ] + cssModulesByChunk.set(chunk, modules) + } + const indices = modules.flatMap((module) => { + const index = group.getModulePostOrderIndex(module) + return index == null ? [] : [index] + }) + return { chunk, index: indices.length > 0 ? Math.min(...indices) : -1 } + }) + .sort((a, b) => a.index - b.index) + return { + preloads: [...preloads], + css: [...new Set(ordered.flatMap(({ chunk }) => readChunk(chunk).css))], + } + } - chunksByFileName.set(file, normalizedChunk) - - if (isEntryChunk && !entryChunkFileName) { - entryChunkFileName = file + const addHydrationCss = ( + group: ChunkGroup, + target: NormalizedClientAssets, + visited: Set, + ) => { + if (visited.has(group)) { + return + } + visited.add(group) + for (const child of group.childrenIterable) { + if (hydrationGroups.has(child)) { + appendAssets(target, { preloads: [], css: readGroup(child).css }) + addHydrationCss(child, target, visited) } } + } - for (const cssFile of cssFiles) { - for (const file of jsFiles) { - const existing = chunksByFileName.get(file) - if (existing && !existing.css.includes(cssFile)) { - existing.css.push(cssFile) - } - } + const routeAssetsByFilePath = new Map() + for (const [filePath, groups] of groupsByRoute) { + const assets: NormalizedClientAssets = { preloads: [], css: [] } + const visited = new Set() + for (const group of groups) { + appendAssets(assets, readGroup(group)) + addHydrationCss(group, assets, visited) } + routeAssetsByFilePath.set(filePath, assets) } - if (!entryChunkFileName) { - throw new Error('No entry file found in rspack client build') + const entryAssets = readGroup(entrypoint) + addHydrationCss(entrypoint, entryAssets, new Set()) + // RSC server-component styles belong to a separate client entry, but must + // still be present in the root route's server-rendered document. + const rscEntrypoint = compilation.entrypoints.get('rsc') + if (rscEntrypoint) { + appendAssets(entryAssets, { + preloads: [], + css: readGroup(rscEntrypoint).css, + }) } + let cssContentByFileName: Map | undefined if (inlineCssEnabled) { cssContentByFileName = new Map() for (const asset of compilation.getAssets()) { if (!asset.name.endsWith('.css')) { continue } - const css = getCssAssetSource(asset.source.source()) if (css !== undefined) { cssContentByFileName.set(asset.name, css) } } } - - // In RSC mode, CSS from server components is associated with the 'rsc' - // client entry chunk (not the main 'index' entry). The manifest builder - // merges the entry chunk's CSS into __root__, so by appending RSC CSS - // to the entry chunk, those stylesheets get loaded on all pages. - // CSS may appear in either `files` or `auxiliaryFiles` depending on - // rspack's CSS extraction strategy. - const rscEntrypoint = compilation.entrypoints.get('rsc') - - if (rscEntrypoint && entryChunkFileName) { - const mainEntryChunk = chunksByFileName.get(entryChunkFileName) - if (mainEntryChunk) { - for (const rscChunk of rscEntrypoint.chunks) { - const allFiles = [...rscChunk.files, ...rscChunk.auxiliaryFiles] - for (const file of allFiles) { - if (file.endsWith('.css') && !mainEntryChunk.css.includes(file)) { - mainEntryChunk.css.push(file) - } - } - } - } - } - return { entryChunkFileName, - chunksByFileName, + entryAssets, + routeAssetsByFilePath, cssContentByFileName, } } @@ -615,9 +294,9 @@ export function registerClientBuildCapture( api: RsbuildPluginAPI, getConfig: GetConfigFn, ): { - getClientBuild: () => NormalizedClientBuild | undefined + getClientBuild: () => NormalizedClientGroupBuild | undefined } { - let clientBuild: NormalizedClientBuild | undefined + let clientBuild: NormalizedClientGroupBuild | undefined api.processAssets( { 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 d5339f2983..d484783f28 100644 --- a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts +++ b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts @@ -15,7 +15,13 @@ import type { ScriptFormat, } from '@tanstack/router-core' import type { InlineCssTemplate } from './inlineCss' -import type { NormalizedClientBuild, NormalizedClientChunk } from '../types' +import type { + NormalizedClientAssets, + NormalizedClientBuild, + NormalizedClientChunk, + NormalizedClientChunkBuild, + NormalizedClientGroupBuild, +} from '../types' type RouteTreeRoute = { filePath?: string @@ -260,6 +266,35 @@ function appendAdditionalRouteEntries( appendRouteScripts(route, scripts) } +function appendAdditionalRouteAssets(options: { + routes: Record + routeTreeRoutes: RouteTreeRoutes + additionalRouteAssets: + | Partial>> + | undefined +}) { + if (!options.additionalRouteAssets) { + return + } + + for (const [routeId, assets] of Object.entries( + options.additionalRouteAssets, + )) { + if (!assets || assets.length === 0) { + continue + } + + if (!(routeId in options.routeTreeRoutes)) { + throw new Error( + `expected additionalRouteAssets routeId to exist in routeTreeRoutes: ${routeId}`, + ) + } + + const route = (options.routes[routeId] = options.routes[routeId] || {}) + appendAdditionalRouteEntries(route, assets) + } +} + export function buildStartManifest(options: { clientBuild: NormalizedClientBuild routeTreeRoutes: RouteTreeRoutes @@ -270,27 +305,33 @@ export function buildStartManifest(options: { Record> > }): StartManifest { - const scannedChunks = scanClientChunks(options.clientBuild) - const assetResolvers = createManifestAssetResolvers( - options.basePath, - scannedChunks.chunksByFileName, - ) - - const routes = buildRouteManifestRoutes({ - routeTreeRoutes: options.routeTreeRoutes, - routeChunksByFilePath: scannedChunks.routeChunksByFilePath, - chunksByFileName: scannedChunks.chunksByFileName, - entryChunk: scannedChunks.entryChunk, - assetResolvers, - additionalRouteAssets: options.additionalRouteAssets, - }) - - appendEntryChunkScripts({ - route: routes[rootRouteId]!, - chunk: scannedChunks.entryChunk, - scriptFormat: options.scriptFormat ?? 'module', - getAssetPath: assetResolvers.getAssetPath, - }) + let routes: Record + if ('routeAssetsByFilePath' in options.clientBuild) { + routes = buildGroupManifestRoutes({ + ...options, + clientBuild: options.clientBuild, + }) + } else { + const scannedChunks = scanClientChunks(options.clientBuild) + const assetResolvers = createManifestAssetResolvers( + options.basePath, + scannedChunks.chunksByFileName, + ) + routes = buildRouteManifestRoutes({ + routeTreeRoutes: options.routeTreeRoutes, + routeChunksByFilePath: scannedChunks.routeChunksByFilePath, + chunksByFileName: scannedChunks.chunksByFileName, + entryChunk: scannedChunks.entryChunk, + assetResolvers, + additionalRouteAssets: options.additionalRouteAssets, + }) + appendEntryChunkScripts({ + route: routes[rootRouteId]!, + chunk: scannedChunks.entryChunk, + scriptFormat: options.scriptFormat ?? 'module', + getAssetPath: assetResolvers.getAssetPath, + }) + } dedupeNestedRouteManifestEntries(rootRouteId, routes[rootRouteId]!, routes) @@ -325,12 +366,69 @@ export function buildStartManifest(options: { return result } +/** Rsbuild already collected complete resources in each route loading context. */ +function buildGroupManifestRoutes(options: { + clientBuild: NormalizedClientGroupBuild + routeTreeRoutes: RouteTreeRoutes + basePath: string + scriptFormat?: ScriptFormat + additionalRouteAssets?: Partial< + Record> + > +}) { + const routes: Record = {} + const assetPath = (file: string) => joinURL(options.basePath, file) + const mergeAssets = ( + route: RouteTreeRoute, + assets: NormalizedClientAssets, + ) => { + route.preloads = appendUniqueStrings( + route.preloads, + assets.preloads.map(assetPath), + ) + appendRouteStylesheets(route, assets.css.map(assetPath)) + } + for (const [routeId, route] of Object.entries(options.routeTreeRoutes)) { + if (!route.filePath && routeId !== rootRouteId) { + throw new Error(`expected filePath to be set for ${routeId}`) + } + const target = (routes[routeId] = { ...route }) + const assets = + route.filePath && + options.clientBuild.routeAssetsByFilePath.get(route.filePath) + if (assets) { + mergeAssets(target, assets) + } + } + const root = (routes[rootRouteId] ??= {}) + mergeAssets(root, options.clientBuild.entryAssets) + const { entryChunkFileName, entryAssets } = options.clientBuild + const scriptFormat = options.scriptFormat ?? 'module' + const scriptFiles = + scriptFormat === 'iife' + ? [ + ...entryAssets.preloads.filter((file) => file !== entryChunkFileName), + entryChunkFileName, + ] + : [entryChunkFileName] + appendAdditionalRouteAssets({ + routes, + routeTreeRoutes: options.routeTreeRoutes, + additionalRouteAssets: options.additionalRouteAssets, + }) + appendRouteScripts( + root, + scriptFiles.map((file) => buildScript(assetPath(file), scriptFormat)), + ) + return routes +} + export function serializeStartManifest(startManifest: StartManifest) { return serialize(startManifest) } export function scanClientChunks( - clientBuild: NormalizedClientBuild, + clientBuild: NormalizedClientChunkBuild, ): ScannedClientChunks { const entryChunk = clientBuild.chunksByFileName.get( clientBuild.entryChunkFileName, @@ -635,24 +733,11 @@ export function buildRouteManifestRoutes(options: { getChunkPreloads: options.assetResolvers.getChunkPreloads, }) - if (options.additionalRouteAssets) { - for (const [routeId, assets] of Object.entries( - options.additionalRouteAssets, - )) { - if (!assets || assets.length === 0) { - continue - } - - if (!(routeId in options.routeTreeRoutes)) { - throw new Error( - `expected additionalRouteAssets routeId to exist in routeTreeRoutes: ${routeId}`, - ) - } - - const route = (routes[routeId] = routes[routeId] || {}) - appendAdditionalRouteEntries(route, assets) - } - } + appendAdditionalRouteAssets({ + routes, + routeTreeRoutes: options.routeTreeRoutes, + additionalRouteAssets: options.additionalRouteAssets, + }) return routes } diff --git a/packages/start-plugin-core/src/types.ts b/packages/start-plugin-core/src/types.ts index db94e343d5..b8fd4ea50f 100644 --- a/packages/start-plugin-core/src/types.ts +++ b/packages/start-plugin-core/src/types.ts @@ -111,7 +111,7 @@ export interface NormalizedClientChunk { fileName: string isEntry: boolean /** - * Chunks this chunk imports statically. Rolldown and Rsbuild report direct + * Chunks this chunk imports statically. Rolldown reports direct * imports only, so consumers must walk the graph to reach transitive chunks * instead of treating the list as complete. */ @@ -122,12 +122,29 @@ export interface NormalizedClientChunk { hydrationIds: Array } -export interface NormalizedClientBuild { +export interface NormalizedClientChunkBuild { entryChunkFileName: string chunksByFileName: ReadonlyMap cssContentByFileName?: ReadonlyMap } +/** File names collected in a loading context, before applying the asset base. */ +export interface NormalizedClientAssets { + preloads: Array + css: Array +} + +export interface NormalizedClientGroupBuild { + entryChunkFileName: string + entryAssets: NormalizedClientAssets + routeAssetsByFilePath: ReadonlyMap + cssContentByFileName?: ReadonlyMap +} + +export type NormalizedClientBuild = + | NormalizedClientChunkBuild + | NormalizedClientGroupBuild + export interface TanStackStartCoreOptions { framework: CompileStartFrameworkOptions defaultEntryPaths: { diff --git a/packages/start-plugin-core/src/vite/start-manifest-plugin/normalized-client-build.ts b/packages/start-plugin-core/src/vite/start-manifest-plugin/normalized-client-build.ts index 0c85fe2eb5..264855019c 100644 --- a/packages/start-plugin-core/src/vite/start-manifest-plugin/normalized-client-build.ts +++ b/packages/start-plugin-core/src/vite/start-manifest-plugin/normalized-client-build.ts @@ -2,7 +2,10 @@ import { tsrSplit } from '@tanstack/router-plugin' import { tssHydrate } from '../../hydration-constants' import { getCssAssetSource } from '../../start-manifest-plugin/inlineCss' import type { Rollup } from 'vite' -import type { NormalizedClientBuild, NormalizedClientChunk } from '../../types' +import type { + NormalizedClientChunk, + NormalizedClientChunkBuild, +} from '../../types' export function normalizeViteClientChunk( chunk: Rollup.OutputChunk, @@ -39,7 +42,7 @@ export function normalizeViteClientChunks( export function normalizeViteClientBuild( clientBundle: Rollup.OutputBundle, inlineCssEnabled = false, -): NormalizedClientBuild { +): NormalizedClientChunkBuild { let entryChunkFileName: string | undefined const chunksByFileName = normalizeViteClientChunks(clientBundle) let cssContentByFileName: Map | undefined diff --git a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts index f3e8c4c5f7..d8373b954f 100644 --- a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts @@ -10,17 +10,18 @@ import { } from '../../src/rsbuild/normalized-client-build' import { buildStartManifest } from '../../src/start-manifest-plugin/manifestBuilder' import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' -import type { GetConfigFn, NormalizedClientBuild } from '../../src/types' +import type { GetConfigFn, NormalizedClientGroupBuild } from '../../src/types' async function buildClientFixture( files: Record, config: Rspack.Configuration = {}, - captureBuild: (compilation: Rspack.Compilation) => NormalizedClientBuild = ( - compilation, - ) => normalizeRspackClientBuild(compilation, true), + captureBuild: ( + compilation: Rspack.Compilation, + ) => NormalizedClientGroupBuild = (compilation) => + normalizeRspackClientBuild(compilation, true), ) { const directory = await mkdtemp(join(tmpdir(), 'tss-rsbuild-css-')) - let clientBuild: NormalizedClientBuild | undefined + let clientBuild: NormalizedClientGroupBuild | undefined const compiler = rspack({ ...config, context: directory, @@ -99,6 +100,26 @@ const stylesheetFiles = { 'dot.svg': '', } +function manifestForRoutes( + clientBuild: NormalizedClientGroupBuild, + directory: string, + routes: Array, +) { + return buildStartManifest({ + clientBuild, + basePath: '/', + routeTreeRoutes: { + __root__: { children: routes.map((route) => `/${route}`) }, + ...Object.fromEntries( + routes.map((route) => [ + `/${route}`, + { filePath: join(directory, `${route}.js`) }, + ]), + ), + }, + }) +} + // Rspack reports module paths using the OS native separator on Windows // (e.g. `C:\app\src\routes\posts.tsx`), while the generated route tree always // records `filePath` with POSIX separators. This compilation mimics that @@ -121,25 +142,38 @@ function makeWindowsCompilation(readCss: () => string | Uint8Array) { { name: 'root.css', source: { source: readCss } }, { name: 'posts.css', source: { source: readCss } }, ] + const entryGroup = { + chunks: [entryChunk], + getEntrypointChunk: () => entryChunk, + isInitial: () => true, + getParents: () => [], + origins: [], + getModulePostOrderIndex: () => null, + childrenIterable: [], + } + const request = String.raw`C:\app\src\routes\posts.tsx?tsr-split=component` + const routeGroup = { + chunks: [routeChunk], + isInitial: () => false, + getParents: () => [entryGroup], + origins: [ + { + request, + module: { blocks: [{ dependencies: [{ request }], blocks: [] }] }, + }, + ], + getModulePostOrderIndex: () => null, + childrenIterable: [], + } const compilation = { - entrypoints: new Map([['index', { chunks: [entryChunk] }]]), - chunks: new Set([entryChunk, routeChunk]), - chunkGroups: [], - moduleGraph: { getConnection: () => undefined }, - chunkGraph: { - getModuleChunksIterable: () => [], - getChunkModules: (chunk: unknown) => - chunk === routeChunk - ? [ - { - dependencies: [], - identifier: () => - 'builtin:swc-loader??ruleSet[0]!C:\\app\\src\\routes\\posts.tsx?tsr-split=component', - nameForCondition: () => 'C:\\app\\src\\routes\\posts.tsx', - }, - ] - : [], + entrypoints: new Map([['index', entryGroup]]), + chunkGroups: [entryGroup, routeGroup], + moduleGraph: { + getModule: () => ({ + nameForCondition: () => String.raw`C:\app\src\routes\posts.tsx`, + }), }, + chunkGraph: { getChunkModulesIterableBySourceType: () => [] }, getAssets, } as unknown as Rspack.Compilation return compilation @@ -228,10 +262,6 @@ describe('normalizeRspackClientBuild', () => { }, }) - expect( - clientBuild.chunksByFileName.get('alpha.js')?.dynamicImports, - ).toContain('lazy.js') - for (const routeOrder of [ ['alpha', 'beta'], ['beta', 'alpha'], @@ -272,6 +302,198 @@ describe('normalizeRspackClientBuild', () => { }, ) + test.each([false, true])( + 'resolves aliased route origins with concatenateModules=%s', + async (concatenateModules) => { + const { clientBuild, directory } = await buildClientFixture( + { + 'index.js': `import { load } from './loader.js'; globalThis.load = load`, + 'loader.js': `export const load = () => import(/* webpackChunkName: "alpha" */ '@route?tsr-split=component')`, + 'alpha.js': `import './alpha.css'; export const component = 'alpha'`, + 'alpha.css': '.alpha { color: red }', + }, + { + resolve: { alias: { '@route': './alpha.js' } }, + optimization: { concatenateModules }, + }, + ) + const manifest = manifestForRoutes(clientBuild, directory, ['alpha']) + expect(manifest.routes['/alpha']?.preloads).toEqual(['/alpha.js']) + expect(manifest.routes['/alpha']?.css).toEqual(['/alpha.css']) + }, + ) + + test('maps every tagged origin when routes share a named loading group', async () => { + const { clientBuild, directory } = await buildClientFixture({ + 'index.js': `globalThis.routes = [ + () => import(/* webpackChunkName: "routes" */ './alpha.js?tsr-split=component'), + () => import(/* webpackChunkName: "routes" */ './beta.js?tsr-split=component'), + ]`, + 'alpha.js': `import './alpha.css'; export const component = 'alpha'`, + 'beta.js': `import './beta.css'; export const component = 'beta'`, + 'alpha.css': '.alpha { color: red }', + 'beta.css': '.beta { color: blue }', + }) + const manifest = manifestForRoutes(clientBuild, directory, [ + 'alpha', + 'beta', + ]) + for (const route of ['/alpha', '/beta']) { + expect(manifest.routes[route]?.preloads).toEqual(['/routes.js']) + expect(manifest.routes[route]?.css).toEqual(['/routes.css']) + } + }) + + test('merges component and loader groups for the same physical route', async () => { + const { clientBuild, directory } = await buildClientFixture({ + 'index.js': `globalThis.routes = [ + () => import(/* webpackChunkName: "component" */ './alpha.js?tsr-split=component'), + () => import(/* webpackChunkName: "loader" */ './alpha.js?tsr-split=loader'), + ]`, + 'alpha.js': `import './alpha.css'; export const route = 'alpha'`, + 'alpha.css': '.alpha { color: red }', + }) + const manifest = manifestForRoutes(clientBuild, directory, ['alpha']) + expect(manifest.routes['/alpha']?.preloads).toEqual( + expect.arrayContaining(['/component.js', '/loader.js']), + ) + expect(manifest.routes['/alpha']?.preloads).toHaveLength(2) + expect(manifest.routes['/alpha']?.css).toEqual( + expect.arrayContaining(['/component.css', '/loader.css']), + ) + expect(manifest.routes['/alpha']?.css).toHaveLength(2) + }) + + test('includes nested deferred hydration CSS without preloading its JS or ordinary lazy children', async () => { + const { clientBuild, directory } = await buildClientFixture({ + 'index.js': `globalThis.routes = [ + () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component'), + () => import(/* webpackChunkName: "beta" */ './beta.js?tsr-split=component'), + ]`, + 'alpha.js': ` + import './alpha.css' + export const deferred = () => import(/* webpackChunkName: "deferred" */ './deferred.js?tss-hydrate=first') + export const lazy = () => import(/* webpackChunkName: "lazy" */ './lazy.js') + `, + 'beta.js': `export const component = 'beta'`, + 'deferred.js': ` + import './deferred.css' + export const nested = () => import(/* webpackChunkName: "nested" */ './nested.js?tss-hydrate=second') + `, + 'nested.js': `import './nested.css'; export const component = 'nested'`, + 'lazy.js': ` + import './lazy.css' + export const deferred = () => import(/* webpackChunkName: "unrelated" */ './unrelated.js?tss-hydrate=third') + `, + 'unrelated.js': `import './unrelated.css'; export const component = 'unrelated'`, + 'alpha.css': '.alpha { color: red }', + 'deferred.css': '.deferred { color: blue }', + 'nested.css': '.nested { color: green }', + 'lazy.css': '.lazy { color: yellow }', + 'unrelated.css': '.unrelated { color: orange }', + }) + const manifest = manifestForRoutes(clientBuild, directory, [ + 'alpha', + 'beta', + ]) + expect(manifest.routes['/alpha']?.preloads).toEqual(['/alpha.js']) + expect(manifest.routes['/alpha']?.css).toEqual([ + '/alpha.css', + '/deferred.css', + '/nested.css', + ]) + expect(manifest.routes['/beta']?.preloads).toEqual(['/beta.js']) + expect(manifest.routes['/beta']?.css).toBeUndefined() + }) + + test('includes hydration CSS from an entrypoint root without preloading hydration JS', async () => { + const { clientBuild, directory } = await buildClientFixture({ + 'index.js': ` + import { widget } from './root.js' + globalThis.widget = widget + globalThis.routes = [ + () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component'), + ] + `, + 'root.js': ` + export const widget = () => import(/* webpackChunkName: "deferred" */ './deferred.js?tss-hydrate=root_widget') + `, + 'alpha.js': ` + import { widget } from './root.js' + export const component = widget + `, + 'deferred.js': `import './deferred.css'; export const component = 'deferred'`, + 'deferred.css': '.deferred { color: blue }', + }) + const manifest = buildStartManifest({ + clientBuild, + basePath: '/', + routeTreeRoutes: { + __root__: { + filePath: join(directory, 'root.js'), + children: ['/alpha'], + }, + '/alpha': { filePath: join(directory, 'alpha.js') }, + }, + }) + const root = manifest.routes.__root__ + const alpha = manifest.routes['/alpha'] + const css = [...(root?.css ?? []), ...(alpha?.css ?? [])] + const preloads = [...(root?.preloads ?? []), ...(alpha?.preloads ?? [])] + + expect(css).toEqual(['/deferred.css']) + expect(preloads).toEqual(expect.arrayContaining(['/index.js', '/alpha.js'])) + expect(preloads).not.toContain('/deferred.js') + }) + + test('preserves initial script order and includes RSC entry CSS without its JS', async () => { + const { clientBuild } = await buildClientFixture( + { + 'index.js': `import { value } from './vendor.js'; globalThis.value = value()`, + 'vendor.js': `export const value = () => 'vendor'`, + 'rsc.js': `import './rsc.css'; globalThis.rsc = true`, + 'rsc.css': '.rsc { color: red }', + }, + { + entry: { index: './index.js', rsc: './rsc.js' }, + optimization: { + runtimeChunk: { name: 'runtime' }, + splitChunks: { + cacheGroups: { + vendor: { + test: /vendor\.js$/, + name: 'vendor', + chunks: 'all', + enforce: true, + }, + }, + }, + }, + }, + ) + const manifest = buildStartManifest({ + clientBuild, + basePath: '/assets/', + routeTreeRoutes: { __root__: {} }, + scriptFormat: 'iife', + additionalRouteAssets: { __root__: [{ attrs: { src: '/custom.js' } }] }, + }) + expect( + manifest.routes.__root__?.scripts?.map((script) => script.attrs?.src), + ).toEqual([ + '/custom.js', + '/assets/runtime.js', + '/assets/vendor.js', + '/assets/index.js', + ]) + expect(manifest.routes.__root__?.css).toEqual(['/assets/rsc.css']) + expect(manifest.routes.__root__?.preloads).toEqual([ + '/assets/runtime.js', + '/assets/vendor.js', + '/assets/index.js', + ]) + }) + test('preloads second-level static dependency chunks and orders their css first', async () => { const { clientBuild, directory } = await buildClientFixture( { @@ -350,8 +572,8 @@ describe('normalizeRspackClientBuild', () => { ]) expect(manifest.routes[`/${route}`]?.preloads).toEqual([ `/${route}.js`, - '/header.js', '/format.js', + '/header.js', ]) } }) @@ -439,7 +661,7 @@ describe('normalizeRspackClientBuild', () => { }) test('excludes tree-shaken imports even when the module is in an initial chunk', async () => { - const { clientBuild } = await buildClientFixture( + const { clientBuild, directory } = await buildClientFixture( { 'index.js': ` import { unused } from './unused.js' @@ -464,14 +686,13 @@ describe('normalizeRspackClientBuild', () => { }, ) - expect(clientBuild.chunksByFileName.get('alpha.js')?.imports).toEqual([]) - expect(clientBuild.chunksByFileName.get('alpha.js')?.css).toEqual([ - 'alpha.css', - ]) + const manifest = manifestForRoutes(clientBuild, directory, ['alpha']) + expect(manifest.routes['/alpha']?.preloads).toEqual(['/alpha.js']) + expect(manifest.routes['/alpha']?.css).toEqual(['/alpha.css']) }) test('uses a dependency in the parent group without importing its copy in another entry', async () => { - const { clientBuild } = await buildClientFixture( + const { clientBuild, directory } = await buildClientFixture( { 'index.js': ` import { header } from './header.js' @@ -486,15 +707,10 @@ describe('normalizeRspackClientBuild', () => { { entry: { index: './index.js', other: './other.js' } }, ) - expect(clientBuild.chunksByFileName.get('index.js')?.css).toEqual([ - 'index.css', - ]) - expect(clientBuild.chunksByFileName.get('other.js')?.css).toEqual([ - 'other.css', - ]) - expect(clientBuild.chunksByFileName.get('alpha.js')?.imports).toEqual([ - 'index.js', - ]) + const manifest = manifestForRoutes(clientBuild, directory, ['alpha']) + expect(manifest.routes.__root__?.css).toEqual(['/index.css']) + expect(manifest.routes['/alpha']?.preloads).toEqual(['/alpha.js']) + expect(manifest.routes['/alpha']?.css).toBeUndefined() }) test('does not import sibling route copies of a module from a shared chunk', async () => { @@ -556,7 +772,6 @@ describe('normalizeRspackClientBuild', () => { }, }) - expect(clientBuild.chunksByFileName.get('shared.js')?.imports).toEqual([]) for (const route of routes) { expect(manifest.routes[`/${route}`]?.css).toEqual([`/${route}.css`]) expect(manifest.routes[`/${route}`]?.preloads).toEqual([ @@ -634,7 +849,6 @@ describe('normalizeRspackClientBuild', () => { }, }) - expect(clientBuild.chunksByFileName.get('shared.js')?.imports).toEqual([]) for (const route of ['alpha', 'beta']) { expect(manifest.routes[`/${route}`]?.css).toEqual([ '/widget.css', @@ -654,7 +868,7 @@ describe('normalizeRspackClientBuild', () => { }) test('does not import a chunk that only one parent of a lazy group supplies', async () => { - const { clientBuild } = await buildClientFixture( + const { clientBuild, directory } = await buildClientFixture( { 'index.js': ` globalThis.routes = [ @@ -665,12 +879,12 @@ describe('normalizeRspackClientBuild', () => { 'first.js': ` import { dep } from './dep.js' export const render = () => dep() + 'first' - export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js') + export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js?tsr-split=component') `, 'second.js': ` import { dep } from './dep.js' export const render = () => dep() + 'second' - export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js') + export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js?tsr-split=component') `, 'lazy.js': `import { dep } from './dep.js'; export const lazy = () => dep()`, 'dep.js': `export const dep = () => 'dep'`, @@ -694,14 +908,20 @@ describe('normalizeRspackClientBuild', () => { }, ) - expect(clientBuild.chunksByFileName.get('first.js')?.imports).toEqual([ - 'dep.js', + const manifest = manifestForRoutes(clientBuild, directory, [ + 'first', + 'second', + 'lazy', ]) - expect(clientBuild.chunksByFileName.get('lazy.js')?.imports).toEqual([]) + expect(manifest.routes['/first']?.preloads).toEqual([ + '/first.js', + '/dep.js', + ]) + expect(manifest.routes['/lazy']?.preloads).toEqual(['/lazy.js']) }) test('imports a chunk that every parent of a lazy group supplies', async () => { - const { clientBuild } = await buildClientFixture( + const { clientBuild, directory } = await buildClientFixture( { 'index.js': ` import { base } from './base.js' @@ -714,12 +934,12 @@ describe('normalizeRspackClientBuild', () => { 'first.js': ` import { common } from './common.js' export const render = () => common() + 'first' - export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js') + export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js?tsr-split=component') `, 'second.js': ` import { common } from './common.js' export const render = () => common() + 'second' - export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js') + export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js?tsr-split=component') `, 'lazy.js': ` import { base } from './base.js' @@ -748,9 +968,14 @@ describe('normalizeRspackClientBuild', () => { }, ) - expect(clientBuild.chunksByFileName.get('lazy.js')?.imports).toEqual([ - 'index.js', - 'common.js', + const manifest = manifestForRoutes(clientBuild, directory, [ + 'first', + 'second', + 'lazy', + ]) + expect(manifest.routes['/lazy']?.preloads).toEqual([ + '/lazy.js', + '/common.js', ]) }) @@ -761,7 +986,7 @@ describe('normalizeRspackClientBuild', () => { 'resolves static imports in cyclic lazy routes regardless of order ($routeOrder)', async ({ routeOrder }) => { const otherRoute = { ping: 'pong', pong: 'ping' } as const - const { clientBuild } = await buildClientFixture( + const { clientBuild, directory } = await buildClientFixture( { 'index.js': `globalThis.routes = [${routeOrder .map( @@ -801,11 +1026,15 @@ describe('normalizeRspackClientBuild', () => { }, ) - expect(clientBuild.chunksByFileName.get('shared.js')?.imports).toEqual([]) + const manifest = manifestForRoutes(clientBuild, directory, [ + 'ping', + 'pong', + ]) for (const route of ['ping', 'pong'] as const) { - const chunk = clientBuild.chunksByFileName.get(`${route}.js`) - expect(chunk?.imports).toEqual(['shared.js']) - expect(chunk?.dynamicImports).toContain(`${otherRoute[route]}.js`) + expect(manifest.routes[`/${route}`]?.preloads).toEqual([ + `/${route}.js`, + '/shared.js', + ]) } }, ) @@ -852,8 +1081,6 @@ describe('normalizeRspackClientBuild', () => { }, }) - expect(clientBuild.chunksByFileName.has('base.js')).toBe(false) - expect(clientBuild.chunksByFileName.has('theme.js')).toBe(false) expect(manifest.routes['/alpha']?.css).toEqual([ '/base.css', '/theme.css', @@ -930,16 +1157,7 @@ describe('normalizeRspackClientBuild', () => { }, ) - test('keys the route chunk by a POSIX path when rspack reports OS native module paths', () => { - const compilation = makeWindowsCompilation(() => '.card{color:red}') - const clientBuild = normalizeRspackClientBuild(compilation) - - expect( - clientBuild.chunksByFileName.get('posts.js')?.routeFilePaths, - ).toEqual(['C:/app/src/routes/posts.tsx']) - }) - - test('gives a route its stylesheet and preload when rspack reports OS native module paths', () => { + test('maps Windows route paths to matching route assets', () => { const compilation = makeWindowsCompilation(() => '.card{color:red}') const clientBuild = normalizeRspackClientBuild(compilation) const manifest = buildStartManifest({