From 59f9e0e277ccea2f3d4831f0aa04b42af3011ea2 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 23 Oct 2026 15:30:16 +0000 Subject: [PATCH 1/3] perf(start-plugin-core): read Rsbuild chunk metadata in one module scan Normalizing the client build scanned every chunk's modules twice, once for route split queries and once for hydration queries, calling the native identifier() each time. Read both from one scan with the same literal-key guards and first-value semantics. Replaying server function metadata at finishMake validated every module's buildInfo with zod, even though nearly all modules carry none. Validate only modules that have the field. --- .../rsbuild-cheaper-compilation-scans.md | 5 + .../src/rsbuild/normalized-client-build.ts | 94 ++++------ .../src/rsbuild/start-compiler-host.ts | 10 +- .../rsbuild/normalized-client-build.bench.ts | 176 ++++++++++++++++++ .../rsbuild/normalized-client-build.test.ts | 78 ++++++++ 5 files changed, 299 insertions(+), 64 deletions(-) create mode 100644 .changeset/rsbuild-cheaper-compilation-scans.md create mode 100644 packages/start-plugin-core/tests/rsbuild/normalized-client-build.bench.ts diff --git a/.changeset/rsbuild-cheaper-compilation-scans.md b/.changeset/rsbuild-cheaper-compilation-scans.md new file mode 100644 index 00000000000..91643180fd9 --- /dev/null +++ b/.changeset/rsbuild-cheaper-compilation-scans.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Reduce per-compilation work in Rsbuild by reading route and hydration metadata in one module scan, and by skipping schema validation for modules without server function metadata. 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..ad0f4335d0c 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -26,16 +26,19 @@ function toPosixPath(filePath: string): string { } /** - * Extract route file paths from rspack module identifiers. + * Extract route file paths and hydration IDs 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 +function getChunkMetadataFromModules(modules: Array): { + routeFilePaths: Array + hydrationIds: Array +} { + const routeFilePaths: Array = [] + const hydrationIds: Array = [] + let seenRouteFilePaths: Set | undefined + let seenHydrationIds: Set | undefined for (const mod of modules) { const identifier = mod.identifier() @@ -48,67 +51,36 @@ function getRouteFilePathsFromModules( 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() + if (queryIndex < 0) { + continue } - 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 + const hasRouteMetadata = query.includes(tsrSplit) + const hasHydrationMetadata = query.includes(tssHydrate) + if (!hasRouteMetadata && !hasHydrationMetadata) { + continue + } - if (!hydrationIds || !seen) { - hydrationIds = [] - seen = new Set() + const params = new URLSearchParams(query) + if (hasRouteMetadata && params.has(tsrSplit)) { + const routeFilePath = toPosixPath( + mod.nameForCondition() ?? resourcePart.slice(0, queryIndex), + ) + if (!seenRouteFilePaths?.has(routeFilePath)) { + routeFilePaths.push(routeFilePath) + ;(seenRouteFilePaths ??= new Set()).add(routeFilePath) + } } - hydrationIds.push(hydrationId) - seen.add(hydrationId) + const hydrationId = hasHydrationMetadata ? params.get(tssHydrate) : null + if (hydrationId && !seenHydrationIds?.has(hydrationId)) { + hydrationIds.push(hydrationId) + ;(seenHydrationIds ??= new Set()).add(hydrationId) + } } - return hydrationIds ?? [] + return { routeFilePaths, hydrationIds } } /** @@ -365,8 +337,8 @@ export function normalizeRspackClientBuild( const modules = getModulesIncludingConcatenated( compilation.chunkGraph.getChunkModules(chunk), ) - const routeFilePaths = getRouteFilePathsFromModules(modules) - const hydrationIds = getHydrationIdsFromModules(modules) + const { routeFilePaths, hydrationIds } = + getChunkMetadataFromModules(modules) const cssFiles = getChunkCssFiles(chunk) // The entry chunk is the one named 'index' in the 'index' entrypoint diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts index 3cd1725fa09..cae59ce7c39 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts @@ -82,9 +82,13 @@ function resolveMetadataLoader(): string { function readServerFnBuildInfo( module: Rspack.Module, ): Record | null { - const result = serverFnBuildInfoSchema.safeParse( - module.buildInfo[SERVER_FN_BUILD_INFO_FIELD], - ) + const metadata = module.buildInfo[SERVER_FN_BUILD_INFO_FIELD] + // Most modules have no metadata, and zod's failure path dominates replay. + if (metadata === undefined) { + return null + } + + const result = serverFnBuildInfoSchema.safeParse(metadata) if (!result.success) { return null } diff --git a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.bench.ts b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.bench.ts new file mode 100644 index 00000000000..6a1ffd587ba --- /dev/null +++ b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.bench.ts @@ -0,0 +1,176 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { promisify } from 'node:util' +import { rspack } from '@rsbuild/core' +import { join } from 'pathe' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { normalizeRspackClientBuild } from '../../src/rsbuild/normalized-client-build' +import type { Rspack } from '@rsbuild/core' + +describe.each( + [8, 128].flatMap((routes) => + [false, true].flatMap((concatenateModules) => + ['route-only', 'mixed'].map((metadata) => ({ + routes, + concatenateModules, + metadata, + })), + ), + ), +)( + 'normalize $routes routes, concatenateModules=$concatenateModules, metadata=$metadata', + ({ routes, concatenateModules, metadata }) => { + let directory: string + let compiler: Rspack.Compiler | undefined + let compilation: Rspack.Compilation + + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'tss-normalize-bench-')) + const files: Record = { + 'index.js': `globalThis.routes = [${Array.from( + { length: routes }, + (_, route) => + `() => import('./route-${route}.js?tsr-split=component${metadata === 'mixed' ? `&tss-hydrate=route-${route}` : ''}')`, + ).join(',')}];`, + 'shared.css': '.shared { display: grid; }', + } + for (let route = 0; route < routes; route++) { + files[`route-${route}.js`] = `${Array.from( + { length: 12 }, + (_, leaf) => + `import { value${leaf} } from './leaf-${route}-${leaf}.js${metadata === 'mixed' ? (leaf % 3 === 0 ? `?tss-hydrate=leaf-${route}` : leaf % 3 === 1 ? '?not-tsr-split=component¬-tss-hydrate=ignored' : '') : ''}';`, + ).join('\n')} + import './shared.css'; + import './route-${route}.css'; + export const render = () => [${Array.from({ length: 12 }, (_, leaf) => `value${leaf}()`).join(',')}];` + files[`route-${route}.css`] = `.route-${route} { color: red; }` + for (let leaf = 0; leaf < 12; leaf++) { + files[`leaf-${route}-${leaf}.js`] = + `export const value${leaf} = () => globalThis['value-${route}-${leaf}'];` + } + } + compiler = rspack({ + context: directory, + mode: 'production', + cache: false, + devtool: false, + entry: { index: './index.js' }, + output: { + path: join(directory, 'dist'), + filename: '[name].js', + chunkFilename: '[id].js', + cssFilename: '[name].css', + cssChunkFilename: '[id].css', + }, + module: { rules: [{ test: /\.css$/, type: 'css' }] }, + optimization: { + minimize: false, + concatenateModules, + splitChunks: { + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + shared: { + test: /shared\.css$/, + name: 'shared', + chunks: 'async', + enforce: true, + }, + }, + }, + }, + plugins: [ + new rspack.experiments.VirtualModulesPlugin( + Object.fromEntries( + Object.entries(files).map(([file, source]) => [ + join(directory, file), + source, + ]), + ), + ), + ], + }) + const stats = await promisify(compiler.run.bind(compiler))() + expect(stats?.hasErrors()).toBe(false) + compilation = stats!.compilation + const modules = new Set( + [...compilation.chunks].flatMap((chunk) => + compilation.chunkGraph.getChunkModules(chunk), + ), + ) + let hasConcatenatedModules = false + for (const module of modules) { + const innerModules = ( + module as Rspack.Module & { modules?: Array } + ).modules + if (innerModules?.length) { + hasConcatenatedModules = true + for (const innerModule of innerModules) { + modules.add(innerModule) + } + } + } + expect(hasConcatenatedModules).toBe(concatenateModules) + expect( + [...modules].filter((module) => + module.nameForCondition()?.includes('/leaf-'), + ), + ).toHaveLength(routes * 12) + const result = normalizeRspackClientBuild(compilation, true) + expect(result.chunksByFileName.size).toBe(routes + 1) + expect( + [...result.chunksByFileName.values()].flatMap( + (chunk) => chunk.routeFilePaths, + ), + ).toHaveLength(routes) + expect(result.cssContentByFileName?.size).toBe(routes + 1) + for (const chunk of result.chunksByFileName.values()) { + if (chunk.isEntry) { + expect(chunk.dynamicImports).toHaveLength(routes) + continue + } + expect(chunk.css).toHaveLength(2) + expect(result.cssContentByFileName?.get(chunk.css[0]!)).toMatch( + /display:\s*grid/, + ) + expect(result.cssContentByFileName?.get(chunk.css[1]!)).toContain( + '.route-', + ) + if (metadata === 'mixed') { + expect(chunk.hydrationIds).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^route-\d+$/), + expect.stringMatching(/^leaf-\d+$/), + ]), + ) + expect(chunk.hydrationIds).not.toContain('ignored') + } else { + expect(chunk.hydrationIds).toEqual([]) + } + } + }) + + afterAll(async () => { + try { + if (compiler) { + await promisify(compiler.close.bind(compiler))() + } + } finally { + if (directory) { + await rm(directory, { recursive: true, force: true }) + } + } + }) + + for (const inlineCss of [false, true]) { + test(`normalize compilation, inlineCss=${inlineCss}`, async ({ + bench, + }) => { + await bench('normalize compilation', () => { + normalizeRspackClientBuild(compilation, inlineCss) + }).run({ time: 3000, warmupIterations: 100 }) + }) + } + }, +) 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..969dc54a01d 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 @@ -145,6 +145,84 @@ function makeWindowsCompilation(readCss: () => string | Uint8Array) { } describe('normalizeRspackClientBuild', () => { + test.each([ + { + query: 'tsr-split=component&tss-hydrate=first&tss-hydrate=second', + route: true, + hydrationIds: ['first'], + }, + { + query: 'tsr-split=component&tss-hydrate=&tss-hydrate=second', + route: true, + hydrationIds: [], + }, + { + query: 'tsr-split=component&tss-hydrate=one+two%2Bthree', + route: true, + hydrationIds: ['one two+three'], + }, + { + query: '%74sr-split=component&tss-hydrate=first', + route: false, + hydrationIds: ['first'], + }, + { + query: 'tsr-split=component&%74ss-hydrate=first', + route: true, + hydrationIds: [], + }, + { + query: 'not-tsr-split=component¬-tss-hydrate=first', + route: false, + hydrationIds: [], + }, + ])( + 'preserves literal query filtering and first values for $query', + async ({ query, route, hydrationIds }) => { + const { clientBuild, directory } = await buildClientFixture({ + 'index.js': `globalThis.route = () => import(/* webpackChunkName: "alpha" */ './alpha.js?${query}')`, + 'alpha.js': `export const render = () => globalThis.render()`, + }) + const chunk = clientBuild.chunksByFileName.get('alpha.js')! + expect(chunk.routeFilePaths).toEqual( + route ? [join(directory, 'alpha.js')] : [], + ) + expect(chunk.hydrationIds).toEqual(hydrationIds) + }, + ) + + test.each([false, true])( + 'preserves route and hydration metadata with concatenateModules=%s', + async (concatenateModules) => { + const { clientBuild, directory } = await buildClientFixture( + { + 'index.js': `globalThis.route = () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component&tss-hydrate=route%2Falpha')`, + 'alpha.js': ` + import { first } from './first.js?tss-hydrate=shared'; + import { second } from './second.js?tss-hydrate=shared'; + import { empty } from './empty.js?tss-hydrate='; + import { lookalike } from './lookalike.js?not-tsr-split=component¬-tss-hydrate=ignored'; + export const render = () => first() + second() + empty() + lookalike(); + `, + 'first.js': `export const first = () => globalThis.first`, + 'second.js': `export const second = () => globalThis.second`, + 'empty.js': `export const empty = () => globalThis.empty`, + 'lookalike.js': `export const lookalike = () => globalThis.lookalike`, + }, + { optimization: { concatenateModules } }, + ) + + const chunk = clientBuild.chunksByFileName.get('alpha.js')! + expect(chunk.routeFilePaths).toEqual([join(directory, 'alpha.js')]) + expect(chunk.hydrationIds).toEqual( + expect.arrayContaining(['route/alpha', 'shared']), + ) + expect(chunk.hydrationIds.filter((id) => id === 'shared')).toHaveLength(1) + expect(chunk.hydrationIds).not.toContain('') + expect(chunk.hydrationIds).not.toContain('ignored') + }, + ) + test.each( ['shared', 'none', 'css-module', 'css-only'].flatMap((extraction) => [false, true].flatMap((concatenateModules) => From d94ace3a452e969eab0557406a321b0099755e2e Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 23 Oct 2026 19:30:57 +0000 Subject: [PATCH 2/3] docs(start-plugin-core): restore Windows path comment in Rsbuild chunk metadata scan --- .../start-plugin-core/src/rsbuild/normalized-client-build.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 ad0f4335d0c..5f3877c0092 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -64,6 +64,10 @@ function getChunkMetadataFromModules(modules: Array): { const params = new URLSearchParams(query) if (hasRouteMetadata && params.has(tsrSplit)) { + // 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( mod.nameForCondition() ?? resourcePart.slice(0, queryIndex), ) From 7d98dc2e125913de78a7ab3c5eae0403b6c2f4ea Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 26 Oct 2026 16:08:15 +0000 Subject: [PATCH 3/3] refactor(start-plugin-core): leave Rsbuild normalization to #8588 --- .../rsbuild-cheaper-compilation-scans.md | 2 +- .../src/rsbuild/normalized-client-build.ts | 98 ++++++---- .../rsbuild/normalized-client-build.bench.ts | 176 ------------------ .../rsbuild/normalized-client-build.test.ts | 78 -------- 4 files changed, 62 insertions(+), 292 deletions(-) delete mode 100644 packages/start-plugin-core/tests/rsbuild/normalized-client-build.bench.ts diff --git a/.changeset/rsbuild-cheaper-compilation-scans.md b/.changeset/rsbuild-cheaper-compilation-scans.md index 91643180fd9..9a9d015f68f 100644 --- a/.changeset/rsbuild-cheaper-compilation-scans.md +++ b/.changeset/rsbuild-cheaper-compilation-scans.md @@ -2,4 +2,4 @@ '@tanstack/start-plugin-core': patch --- -Reduce per-compilation work in Rsbuild by reading route and hydration metadata in one module scan, and by skipping schema validation for modules without server function metadata. +Skip server function metadata validation for Rsbuild modules that have no server function metadata. 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 5f3877c0092..591d6b0113a 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -26,19 +26,16 @@ function toPosixPath(filePath: string): string { } /** - * Extract route file paths and hydration IDs from rspack module identifiers. + * 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 getChunkMetadataFromModules(modules: Array): { - routeFilePaths: Array - hydrationIds: Array -} { - const routeFilePaths: Array = [] - const hydrationIds: Array = [] - let seenRouteFilePaths: Set | undefined - let seenHydrationIds: Set | undefined +function getRouteFilePathsFromModules( + modules: Array, +): Array { + let routeFilePaths: Array | undefined + let seen: Set | undefined for (const mod of modules) { const identifier = mod.identifier() @@ -51,40 +48,67 @@ function getChunkMetadataFromModules(modules: Array): { lastBangIndex >= 0 ? identifier.slice(lastBangIndex + 1) : identifier const queryIndex = resourcePart.indexOf('?') - if (queryIndex < 0) { - continue - } + if (queryIndex < 0) continue const query = resourcePart.slice(queryIndex + 1) - const hasRouteMetadata = query.includes(tsrSplit) - const hasHydrationMetadata = query.includes(tssHydrate) - if (!hasRouteMetadata && !hasHydrationMetadata) { - continue - } + if (!query.includes(tsrSplit)) continue + if (!new URLSearchParams(query).has(tsrSplit)) continue - const params = new URLSearchParams(query) - if (hasRouteMetadata && params.has(tsrSplit)) { - // 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( - mod.nameForCondition() ?? resourcePart.slice(0, queryIndex), - ) - if (!seenRouteFilePaths?.has(routeFilePath)) { - routeFilePaths.push(routeFilePath) - ;(seenRouteFilePaths ??= new Set()).add(routeFilePath) - } + 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() } - const hydrationId = hasHydrationMetadata ? params.get(tssHydrate) : null - if (hydrationId && !seenHydrationIds?.has(hydrationId)) { - hydrationIds.push(hydrationId) - ;(seenHydrationIds ??= new Set()).add(hydrationId) + 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 { routeFilePaths, hydrationIds } + return hydrationIds ?? [] } /** @@ -341,8 +365,8 @@ export function normalizeRspackClientBuild( const modules = getModulesIncludingConcatenated( compilation.chunkGraph.getChunkModules(chunk), ) - const { routeFilePaths, hydrationIds } = - getChunkMetadataFromModules(modules) + const routeFilePaths = getRouteFilePathsFromModules(modules) + const hydrationIds = getHydrationIdsFromModules(modules) const cssFiles = getChunkCssFiles(chunk) // The entry chunk is the one named 'index' in the 'index' entrypoint diff --git a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.bench.ts b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.bench.ts deleted file mode 100644 index 6a1ffd587ba..00000000000 --- a/packages/start-plugin-core/tests/rsbuild/normalized-client-build.bench.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { promisify } from 'node:util' -import { rspack } from '@rsbuild/core' -import { join } from 'pathe' -import { afterAll, beforeAll, describe, expect, test } from 'vitest' -import { normalizeRspackClientBuild } from '../../src/rsbuild/normalized-client-build' -import type { Rspack } from '@rsbuild/core' - -describe.each( - [8, 128].flatMap((routes) => - [false, true].flatMap((concatenateModules) => - ['route-only', 'mixed'].map((metadata) => ({ - routes, - concatenateModules, - metadata, - })), - ), - ), -)( - 'normalize $routes routes, concatenateModules=$concatenateModules, metadata=$metadata', - ({ routes, concatenateModules, metadata }) => { - let directory: string - let compiler: Rspack.Compiler | undefined - let compilation: Rspack.Compilation - - beforeAll(async () => { - directory = await mkdtemp(join(tmpdir(), 'tss-normalize-bench-')) - const files: Record = { - 'index.js': `globalThis.routes = [${Array.from( - { length: routes }, - (_, route) => - `() => import('./route-${route}.js?tsr-split=component${metadata === 'mixed' ? `&tss-hydrate=route-${route}` : ''}')`, - ).join(',')}];`, - 'shared.css': '.shared { display: grid; }', - } - for (let route = 0; route < routes; route++) { - files[`route-${route}.js`] = `${Array.from( - { length: 12 }, - (_, leaf) => - `import { value${leaf} } from './leaf-${route}-${leaf}.js${metadata === 'mixed' ? (leaf % 3 === 0 ? `?tss-hydrate=leaf-${route}` : leaf % 3 === 1 ? '?not-tsr-split=component¬-tss-hydrate=ignored' : '') : ''}';`, - ).join('\n')} - import './shared.css'; - import './route-${route}.css'; - export const render = () => [${Array.from({ length: 12 }, (_, leaf) => `value${leaf}()`).join(',')}];` - files[`route-${route}.css`] = `.route-${route} { color: red; }` - for (let leaf = 0; leaf < 12; leaf++) { - files[`leaf-${route}-${leaf}.js`] = - `export const value${leaf} = () => globalThis['value-${route}-${leaf}'];` - } - } - compiler = rspack({ - context: directory, - mode: 'production', - cache: false, - devtool: false, - entry: { index: './index.js' }, - output: { - path: join(directory, 'dist'), - filename: '[name].js', - chunkFilename: '[id].js', - cssFilename: '[name].css', - cssChunkFilename: '[id].css', - }, - module: { rules: [{ test: /\.css$/, type: 'css' }] }, - optimization: { - minimize: false, - concatenateModules, - splitChunks: { - minSize: 0, - cacheGroups: { - default: false, - defaultVendors: false, - shared: { - test: /shared\.css$/, - name: 'shared', - chunks: 'async', - enforce: true, - }, - }, - }, - }, - plugins: [ - new rspack.experiments.VirtualModulesPlugin( - Object.fromEntries( - Object.entries(files).map(([file, source]) => [ - join(directory, file), - source, - ]), - ), - ), - ], - }) - const stats = await promisify(compiler.run.bind(compiler))() - expect(stats?.hasErrors()).toBe(false) - compilation = stats!.compilation - const modules = new Set( - [...compilation.chunks].flatMap((chunk) => - compilation.chunkGraph.getChunkModules(chunk), - ), - ) - let hasConcatenatedModules = false - for (const module of modules) { - const innerModules = ( - module as Rspack.Module & { modules?: Array } - ).modules - if (innerModules?.length) { - hasConcatenatedModules = true - for (const innerModule of innerModules) { - modules.add(innerModule) - } - } - } - expect(hasConcatenatedModules).toBe(concatenateModules) - expect( - [...modules].filter((module) => - module.nameForCondition()?.includes('/leaf-'), - ), - ).toHaveLength(routes * 12) - const result = normalizeRspackClientBuild(compilation, true) - expect(result.chunksByFileName.size).toBe(routes + 1) - expect( - [...result.chunksByFileName.values()].flatMap( - (chunk) => chunk.routeFilePaths, - ), - ).toHaveLength(routes) - expect(result.cssContentByFileName?.size).toBe(routes + 1) - for (const chunk of result.chunksByFileName.values()) { - if (chunk.isEntry) { - expect(chunk.dynamicImports).toHaveLength(routes) - continue - } - expect(chunk.css).toHaveLength(2) - expect(result.cssContentByFileName?.get(chunk.css[0]!)).toMatch( - /display:\s*grid/, - ) - expect(result.cssContentByFileName?.get(chunk.css[1]!)).toContain( - '.route-', - ) - if (metadata === 'mixed') { - expect(chunk.hydrationIds).toEqual( - expect.arrayContaining([ - expect.stringMatching(/^route-\d+$/), - expect.stringMatching(/^leaf-\d+$/), - ]), - ) - expect(chunk.hydrationIds).not.toContain('ignored') - } else { - expect(chunk.hydrationIds).toEqual([]) - } - } - }) - - afterAll(async () => { - try { - if (compiler) { - await promisify(compiler.close.bind(compiler))() - } - } finally { - if (directory) { - await rm(directory, { recursive: true, force: true }) - } - } - }) - - for (const inlineCss of [false, true]) { - test(`normalize compilation, inlineCss=${inlineCss}`, async ({ - bench, - }) => { - await bench('normalize compilation', () => { - normalizeRspackClientBuild(compilation, inlineCss) - }).run({ time: 3000, warmupIterations: 100 }) - }) - } - }, -) 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 969dc54a01d..f563c57bf7d 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 @@ -145,84 +145,6 @@ function makeWindowsCompilation(readCss: () => string | Uint8Array) { } describe('normalizeRspackClientBuild', () => { - test.each([ - { - query: 'tsr-split=component&tss-hydrate=first&tss-hydrate=second', - route: true, - hydrationIds: ['first'], - }, - { - query: 'tsr-split=component&tss-hydrate=&tss-hydrate=second', - route: true, - hydrationIds: [], - }, - { - query: 'tsr-split=component&tss-hydrate=one+two%2Bthree', - route: true, - hydrationIds: ['one two+three'], - }, - { - query: '%74sr-split=component&tss-hydrate=first', - route: false, - hydrationIds: ['first'], - }, - { - query: 'tsr-split=component&%74ss-hydrate=first', - route: true, - hydrationIds: [], - }, - { - query: 'not-tsr-split=component¬-tss-hydrate=first', - route: false, - hydrationIds: [], - }, - ])( - 'preserves literal query filtering and first values for $query', - async ({ query, route, hydrationIds }) => { - const { clientBuild, directory } = await buildClientFixture({ - 'index.js': `globalThis.route = () => import(/* webpackChunkName: "alpha" */ './alpha.js?${query}')`, - 'alpha.js': `export const render = () => globalThis.render()`, - }) - const chunk = clientBuild.chunksByFileName.get('alpha.js')! - expect(chunk.routeFilePaths).toEqual( - route ? [join(directory, 'alpha.js')] : [], - ) - expect(chunk.hydrationIds).toEqual(hydrationIds) - }, - ) - - test.each([false, true])( - 'preserves route and hydration metadata with concatenateModules=%s', - async (concatenateModules) => { - const { clientBuild, directory } = await buildClientFixture( - { - 'index.js': `globalThis.route = () => import(/* webpackChunkName: "alpha" */ './alpha.js?tsr-split=component&tss-hydrate=route%2Falpha')`, - 'alpha.js': ` - import { first } from './first.js?tss-hydrate=shared'; - import { second } from './second.js?tss-hydrate=shared'; - import { empty } from './empty.js?tss-hydrate='; - import { lookalike } from './lookalike.js?not-tsr-split=component¬-tss-hydrate=ignored'; - export const render = () => first() + second() + empty() + lookalike(); - `, - 'first.js': `export const first = () => globalThis.first`, - 'second.js': `export const second = () => globalThis.second`, - 'empty.js': `export const empty = () => globalThis.empty`, - 'lookalike.js': `export const lookalike = () => globalThis.lookalike`, - }, - { optimization: { concatenateModules } }, - ) - - const chunk = clientBuild.chunksByFileName.get('alpha.js')! - expect(chunk.routeFilePaths).toEqual([join(directory, 'alpha.js')]) - expect(chunk.hydrationIds).toEqual( - expect.arrayContaining(['route/alpha', 'shared']), - ) - expect(chunk.hydrationIds.filter((id) => id === 'shared')).toHaveLength(1) - expect(chunk.hydrationIds).not.toContain('') - expect(chunk.hydrationIds).not.toContain('ignored') - }, - ) - test.each( ['shared', 'none', 'css-module', 'css-only'].flatMap((extraction) => [false, true].flatMap((concatenateModules) =>