diff --git a/.changeset/rsbuild-static-chunk-availability.md b/.changeset/rsbuild-static-chunk-availability.md new file mode 100644 index 00000000000..cd918ed75a1 --- /dev/null +++ b/.changeset/rsbuild-static-chunk-availability.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +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/e2e/react-start/deferred-hydration/src/routes/__root.tsx b/e2e/react-start/deferred-hydration/src/routes/__root.tsx index bfdc646fc58..4f9e6a4bf42 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 00000000000..11b1a7874a5 --- /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 bd097d2ead2..050d50ba6b1 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 591d6b0113a..351893c2fea 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -5,465 +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 RspackModule = Rspack.Module +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) + return ( + (file.endsWith('.js') || file.endsWith('.mjs')) && + !file.includes('.hot-update.') + ) } -/** - * 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 +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], + ) } -/** - * 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) +/** 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 + } + const target = compilation.moduleGraph.getModule(dependency) + const filePath = target?.nameForCondition() + if (filePath) { + paths.set(request, filePath.replace(/\\/g, '/')) + } } } - - return cssFiles + return paths } -/** - * 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) +/** 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(), + })) + let changed = true + while (changed) { + changed = false + for (const { group, chunks, parents } of records) { + let inherited: Set | undefined + for (const parent of parents) { + 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 next = inherited ?? new Set() + for (const chunk of chunks) { + next.add(chunk) + } + // 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 Array.from(allModules) + return available } -/** - * 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) - } - } +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 dynamicImportFiles } -/** - * 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. - */ -function computeStaticDependencyChunks( - compilation: RspackCompilation, - chunk: RspackCompilationChunk, -): Array { - const importedChunks: 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 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) - } +/** 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') } - 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)) { + 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 } - if (connection.getActiveState(runtime) === false) { + const query = getRequestQuery(origin.request) + if (query.get(tssHydrate)) { + hydrationGroups.add(group) + } + if (!query.has(tsrSplit) || !origin.module) { continue } - - for (const importedChunk of compilation.chunkGraph.getModuleChunksIterable( - importedModule, - )) { - if (availableChunks.has(importedChunk) && !seen.has(importedChunk)) { - seen.add(importedChunk) - importedChunks.push(importedChunk) - } + let imports = importsByModule.get(origin.module) + if (!imports) { + imports = getTaggedImportPaths(compilation, origin.module) + importsByModule.set(origin.module, imports) } - } - } - - return importedChunks -} - -type GetStaticDependencyChunks = ( - chunk: RspackCompilationChunk, -) => Array - -/** - * 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. - */ -function createStaticDependencyChunkResolver( - compilation: RspackCompilation, -): GetStaticDependencyChunks { - const cache = new Map>() - - return (chunk) => { - let importedChunks = cache.get(chunk) - if (!importedChunks) { - importedChunks = computeStaticDependencyChunks(compilation, chunk) - cache.set(chunk, importedChunks) - } - return importedChunks - } -} - -/** - * 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) + 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 getStaticDependencyChunks = - 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 = getStaticDependencyChunks(chunk) - const dependencyCssFiles = computeCssOnlyDependencyFiles( - 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, } } @@ -476,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 d5339f29832..d484783f28f 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 db94e343d54..b8fd4ea50fe 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 0c85fe2eb55..264855019c5 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 f563c57bf7d..d8373b954f6 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,24 +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]), - 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 @@ -227,10 +262,6 @@ describe('normalizeRspackClientBuild', () => { }, }) - expect( - clientBuild.chunksByFileName.get('alpha.js')?.dynamicImports, - ).toContain('lazy.js') - for (const routeOrder of [ ['alpha', 'beta'], ['beta', 'alpha'], @@ -271,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( { @@ -349,8 +572,8 @@ describe('normalizeRspackClientBuild', () => { ]) expect(manifest.routes[`/${route}`]?.preloads).toEqual([ `/${route}.js`, - '/header.js', '/format.js', + '/header.js', ]) } }) @@ -438,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' @@ -463,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' @@ -485,17 +707,338 @@ describe('normalizeRspackClientBuild', () => { { entry: { index: './index.js', other: './other.js' } }, ) - expect(clientBuild.chunksByFileName.get('index.js')?.css).toEqual([ - 'index.css', + 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 () => { + 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`) }, + ]), + ), + }, + }) + + for (const route of routes) { + expect(manifest.routes[`/${route}`]?.css).toEqual([`/${route}.css`]) + expect(manifest.routes[`/${route}`]?.preloads).toEqual([ + `/${route}.js`, + '/shared.js', + ]) + 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`) }, + ]), + ), + }, + }) + + 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, directory } = 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?tsr-split=component') + `, + 'second.js': ` + import { dep } from './dep.js' + export const render = () => dep() + 'second' + 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'`, + }, + { + optimization: { + splitChunks: { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + dep: { + name: 'dep', + test: /dep\.js$/, + chunks: (chunk) => chunk.name === 'first', + enforce: true, + }, + }, + }, + }, + }, + ) + + const manifest = manifestForRoutes(clientBuild, directory, [ + 'first', + 'second', + 'lazy', ]) - expect(clientBuild.chunksByFileName.get('other.js')?.css).toEqual([ - 'other.css', + expect(manifest.routes['/first']?.preloads).toEqual([ + '/first.js', + '/dep.js', ]) - expect(clientBuild.chunksByFileName.get('alpha.js')?.imports).toEqual([ - 'index.js', + expect(manifest.routes['/lazy']?.preloads).toEqual(['/lazy.js']) + }) + + test('imports a chunk that every parent of a lazy group supplies', async () => { + const { clientBuild, directory } = 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?tsr-split=component') + `, + 'second.js': ` + import { common } from './common.js' + export const render = () => common() + 'second' + export const open = () => import(/* webpackChunkName: "lazy" */ './lazy.js?tsr-split=component') + `, + '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, + }, + }, + }, + }, + }, + ) + + const manifest = manifestForRoutes(clientBuild, directory, [ + 'first', + 'second', + 'lazy', + ]) + expect(manifest.routes['/lazy']?.preloads).toEqual([ + '/lazy.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).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': `export const widget = () => 'widget'`, + }, + { + optimization: { + splitChunks: { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + shared: { + name: 'shared', + test: /shared\.js$/, + chunks: 'async', + enforce: true, + }, + }, + }, + }, + }, + ) + + const manifest = manifestForRoutes(clientBuild, directory, [ + 'ping', + 'pong', + ]) + for (const route of ['ping', 'pong'] as const) { + expect(manifest.routes[`/${route}`]?.preloads).toEqual([ + `/${route}.js`, + '/shared.js', + ]) + } + }, + ) + test('collects nested CSS imports before the importing route stylesheet', async () => { const { clientBuild, directory } = await buildClientFixture( { @@ -538,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', @@ -616,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({