diff --git a/.changeset/rsbuild-windows-route-manifest.md b/.changeset/rsbuild-windows-route-manifest.md new file mode 100644 index 0000000000..ec78789008 --- /dev/null +++ b/.changeset/rsbuild-windows-route-manifest.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Fix the Rsbuild Start manifest dropping every route's stylesheets and preloads on Windows by normalizing rspack module paths to the POSIX form the generated route tree uses. 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 60244fdca9..9660cdaa8e 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -12,6 +12,15 @@ type RspackCompilation = Rspack.Compilation type RspackCompilationChunk = Rspack.Chunk type RspackModule = Rspack.Module +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. * @@ -42,7 +51,14 @@ function getRouteFilePathsFromModules( if (!new URLSearchParams(query).has(tsrSplit)) continue const nameForCondition = mod.nameForCondition() - const routeFilePath = nameForCondition ?? resourcePart.slice(0, queryIndex) + + // 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 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 new file mode 100644 index 0000000000..08ae2fd923 --- /dev/null +++ b/packages/start-plugin-core/tests/rsbuild/normalized-client-build.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'vitest' +import { normalizeRspackClientBuild } from '../../src/rsbuild/normalized-client-build' +import { buildStartManifest } from '../../src/start-manifest-plugin/manifestBuilder' +import type { Rspack } from '@rsbuild/core' + +function makeModule(options: { + identifier: string + nameForCondition?: string | null +}): Rspack.Module { + return { + identifier: () => options.identifier, + nameForCondition: () => options.nameForCondition ?? null, + } as unknown as Rspack.Module +} + +function makeChunk(options: { + name: string + files: Array + auxiliaryFiles?: Array +}): Rspack.Chunk { + const chunk = { + name: options.name, + files: new Set(options.files), + auxiliaryFiles: new Set(options.auxiliaryFiles ?? []), + } as unknown as Rspack.Chunk + + const group = { + chunks: [chunk], + childrenIterable: new Set(), + } + + ;(chunk as any).groupsIterable = new Set([group]) + + return chunk +} + +function makeCompilation( + entries: Array<{ chunk: Rspack.Chunk; modules: Array }>, +): Rspack.Compilation { + const entryChunk = entries.find((entry) => entry.chunk.name === 'index')! + const modulesByChunk = new Map( + entries.map((entry) => [entry.chunk, entry.modules]), + ) + + return { + entrypoints: new Map([['index', { chunks: [entryChunk.chunk] }]]), + chunks: new Set(entries.map((entry) => entry.chunk)), + chunkGraph: { + getChunkModules: (chunk: Rspack.Chunk) => modulesByChunk.get(chunk) ?? [], + }, + getAssets: () => [], + } as unknown as Rspack.Compilation +} + +function makeWindowsCompilation() { + return makeCompilation([ + { + chunk: makeChunk({ name: 'index', files: ['index.js'] }), + modules: [makeModule({ identifier: 'C:\\app\\src\\client.tsx' })], + }, + { + chunk: makeChunk({ + name: 'posts', + files: ['posts.js'], + auxiliaryFiles: ['posts.css'], + }), + modules: [ + makeModule({ + identifier: + 'builtin:swc-loader??ruleSet[0]!C:\\app\\src\\routes\\posts.tsx?tsr-split=component', + nameForCondition: 'C:\\app\\src\\routes\\posts.tsx', + }), + ], + }, + ]) +} + +describe('normalizeRspackClientBuild', () => { + test('keys route chunks by a POSIX path when rspack reports OS native paths', () => { + const build = normalizeRspackClientBuild(makeWindowsCompilation()) + + expect([...build.chunkFileNamesByRouteFilePath.keys()]).toEqual([ + 'C:/app/src/routes/posts.tsx', + ]) + expect(build.chunksByFileName.get('posts.js')?.routeFilePaths).toEqual([ + 'C:/app/src/routes/posts.tsx', + ]) + }) + + test('gives a route its stylesheet when rspack reports OS native paths', () => { + const manifest = buildStartManifest({ + clientBuild: normalizeRspackClientBuild(makeWindowsCompilation()), + routeTreeRoutes: { + __root__: { + filePath: 'C:/app/src/routes/__root.tsx', + children: ['/posts'], + }, + '/posts': { filePath: 'C:/app/src/routes/posts.tsx', children: [] }, + }, + basePath: '/', + }) + + expect(manifest.routes['/posts']?.css).toEqual(['/posts.css']) + expect(manifest.routes['/posts']?.preloads).toEqual(['/posts.js']) + }) +})