Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/route-entry-names-relative.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'rsbuild-plugin-react-router': patch
---

Derive route entry names from the route file instead of the route id. A route
table built with `relative()` resolves route files to absolute paths, so React
Router relativizes `file` but leaves `id` absolute, and the plugin used that
opaque id as an rspack entry name. Split route module chunks were therefore
emitted into a directory tree mirroring the developer's checkout
(`static/js/Users/<user>/.../customers-client-loader.js`) and the browser
manifest published those paths, leaking `$HOME` into production assets and
making builds unreproducible across machines and CI. Entry names are now
app-relative for both the route entry and its chunks, so a chunk lands beside
its route, the `"/static/js//..."` double slash is gone, and an entry can no
longer escape the JS output directory or carry a Windows drive prefix. Route
ids are untouched: they remain the runtime contract behind
`useRouteLoaderData(id)` and `matches[].id`. Note that route chunks for routes
declared with an explicit `id` are renamed accordingly, which changes those
asset URLs.
9 changes: 7 additions & 2 deletions src/classic-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import {
getRouteChunkEntryName,
getRouteChunkModuleId,
getRouteEntryBaseName,
routeChunkExportNames,
} from './route-chunks.js';
import type { Config } from './react-router-config.js';
Expand Down Expand Up @@ -144,7 +145,7 @@ export const createClassicWebRouteEntries = ({
const manifestChunkNames = new Set<string>(['entry.client']);
const webRouteEntries = Object.values(routes).reduce(
(acc, route) => {
const entryName = route.file.slice(0, route.file.lastIndexOf('.'));
const entryName = getRouteEntryBaseName(route, appDirectory);
const routeFilePath = resolve(appDirectory, route.file);
manifestChunkNames.add(entryName);
acc[entryName] = {
Expand All @@ -166,7 +167,11 @@ export const createClassicWebRouteEntries = ({
if (!source.includes(exportName)) {
continue;
}
const chunkEntryName = getRouteChunkEntryName(route.id, exportName);
const chunkEntryName = getRouteChunkEntryName(
route,
exportName,
appDirectory
);
manifestChunkNames.add(chunkEntryName);
acc[chunkEntryName] = {
import: getRouteChunkModuleId(routeFilePath, exportName),
Expand Down
24 changes: 14 additions & 10 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createEmptyRouteChunkByExportName,
detectRouteChunksIfEnabled,
getRouteChunkEntryName,
getRouteEntryBaseName,
routeChunkExportNames,
validateRouteChunks,
type RouteChunkCache,
Expand Down Expand Up @@ -425,37 +426,35 @@ const getManifestVersion = (
.slice(0, 8);
};

const getRouteEntryName = (route: Route): string => {
const extensionIndex = route.file.lastIndexOf('.');
return extensionIndex >= 0 ? route.file.slice(0, extensionIndex) : route.file;
};

export const getReactRouterManifestChunkNames = (
routes: Record<string, Route>,
appDirectory: string,
splitRouteModules: boolean | 'enforce' = false
): Set<string> => {
const chunkNames = new Set<string>(['entry.client']);
for (const route of Object.values(routes)) {
chunkNames.add(getRouteEntryName(route));
chunkNames.add(getRouteEntryBaseName(route, appDirectory));
if (!splitRouteModules || route.id === 'root') {
continue;
}
for (const exportName of routeChunkExportNames) {
chunkNames.add(getRouteChunkEntryName(route.id, exportName));
chunkNames.add(getRouteChunkEntryName(route, exportName, appDirectory));
}
}
return chunkNames;
};

const createRouteManifestItem = ({
route,
appDirectory,
assetPrefix,
jsAssets,
routeAnalysis,
getModulePathForChunk,
getCssAssetsForChunk,
}: {
route: Route;
appDirectory: string;
assetPrefix: string;
jsAssets: string[];
routeAnalysis: RouteManifestAnalysis;
Expand All @@ -465,13 +464,17 @@ const createRouteManifestItem = ({
const routeChunkMap = routeAnalysis.hasRouteChunkByExportName;
const chunkModulePath = (exportName: RouteChunkExportName) =>
routeChunkMap?.[exportName]
? getModulePathForChunk(getRouteChunkEntryName(route.id, exportName))
? getModulePathForChunk(
getRouteChunkEntryName(route, exportName, appDirectory)
)
: undefined;
const cssAssets = [
...routeAnalysis.cssAssets,
...routeChunkExportNames.flatMap(exportName =>
routeChunkMap?.[exportName]
? getCssAssetsForChunk(getRouteChunkEntryName(route.id, exportName))
? getCssAssetsForChunk(
getRouteChunkEntryName(route, exportName, appDirectory)
)
: []
),
];
Expand Down Expand Up @@ -540,7 +543,7 @@ function generateReactRouterManifestForDevEffect(
Object.entries(routes),
([key, route]) =>
Effect.gen(function* () {
const routeEntryName = getRouteEntryName(route);
const routeEntryName = getRouteEntryBaseName(route, context);
const { js: jsAssets, css: discoveredCssAssets } =
getAssetsForChunk(routeEntryName);
const routeFilePath = resolve(context, route.file);
Expand Down Expand Up @@ -571,6 +574,7 @@ function generateReactRouterManifestForDevEffect(
key,
createRouteManifestItem({
route,
appDirectory: context,
assetPrefix,
jsAssets,
routeAnalysis,
Expand Down
1 change: 1 addition & 0 deletions src/modify-browser-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export function registerModifyBrowserManifestAssets(
options?.manifestChunkNames ??
getReactRouterManifestChunkNames(
routes,
appDirectory,
routeChunkOptions?.splitRouteModules
);
const isBuild = Boolean(routeChunkOptions?.isBuild);
Expand Down
32 changes: 29 additions & 3 deletions src/route-chunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,19 @@ const normalizeRelativeFilePath = (file: string, appDirectory: string) => {
return normalize(relativePath).split('?')[0];
};

// An rspack entry name is written out as a path under `output.distPath.js`, so it
// is a filename, not an identifier. Keep it a safe, app-relative POSIX path: the
// developer's absolute checkout path must never reach `dist/` (nor the browser
// manifest), and an entry must never escape the JS output directory. `pathe`
// already normalizes separators, so only drive prefixes and `..` need handling.
const toSafeEntryPath = (relativePath: string): string =>
relativePath
.replace(/^[A-Za-z]:/, '')
.split('/')
.filter(segment => segment !== '' && segment !== '.')
.map(segment => (segment === '..' ? '__' : segment))
.join('/');

const isRootRouteModuleId = (config: RouteChunkConfig, id: string) =>
normalizeRelativeFilePath(id, config.appDirectory) === config.rootRouteFile;

Expand Down Expand Up @@ -967,7 +980,20 @@ export const validateRouteChunks: (args: {
);
};

export const getRouteEntryBaseName = (
route: { file: string },
appDirectory: string
): string =>
toSafeEntryPath(normalizeRelativeFilePath(route.file, appDirectory)).replace(
/\.[^/.]+$/,
''
);

export const getRouteChunkEntryName = (
routeId: string,
chunkName: RouteChunkExportName
) => `${routeId}-${routeChunkEntrySuffix[chunkName]}`;
route: { file: string },
chunkName: RouteChunkExportName,
appDirectory: string
): string =>
`${getRouteEntryBaseName(route, appDirectory)}-${
routeChunkEntrySuffix[chunkName]
}`;
99 changes: 99 additions & 0 deletions tests/classic-web-route-entries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from '@rstest/core';
import { createClassicWebRouteEntries } from '../src/classic-mode';
import type { Route } from '../src/types';

const ROUTE_SOURCE = `export async function clientLoader() { return {}; }
export default function X() { return null; }`;

/**
* Lays out a project whose route module lives *outside* `app/`, which is what a
* shared route package or a `relative()` route table produces.
*/
const createAppWithOutsideRoute = () => {
const root = mkdtempSync(join(tmpdir(), 'rr-entries-'));
const appDir = join(root, 'app');
const sharedDir = join(root, 'shared');
mkdirSync(appDir, { recursive: true });
mkdirSync(sharedDir, { recursive: true });

writeFileSync(
join(appDir, 'root.tsx'),
`export default function Root() { return null; }`
);
writeFileSync(join(sharedDir, 'x.tsx'), ROUTE_SOURCE);

return { root, appDir };
};

describe('createClassicWebRouteEntries', () => {
it('never emits an entry name that escapes the JS output directory', () => {
const { root, appDir } = createAppWithOutsideRoute();
const routes: Record<string, Route> = {
root: { id: 'root', file: 'root.tsx', path: '' },
'shared/x': {
id: 'shared/x',
parentId: 'root',
file: '../shared/x.tsx',
path: 'x',
},
};

try {
const { webRouteEntries } = createClassicWebRouteEntries({
appDirectory: appDir,
isBuild: true,
routes,
splitRouteModules: true,
});

expect(Object.keys(webRouteEntries).sort()).toEqual([
'__/shared/x',
'__/shared/x-client-loader',
'root',
]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('shares one entry between routes that point at the same file', () => {
// React Router requires explicit ids when two routes reuse a file. Both
// routes import the very same module, and a route chunk is a pure function
// of (file, export) — so one entry serves both instead of emitting two
// byte-identical chunks.
const root = mkdtempSync(join(tmpdir(), 'rr-entries-'));
const appDir = join(root, 'app');
mkdirSync(join(appDir, 'routes'), { recursive: true });
writeFileSync(
join(appDir, 'root.tsx'),
`export default function Root() { return null; }`
);
writeFileSync(join(appDir, 'routes', 'shared.tsx'), ROUTE_SOURCE);

const routes: Record<string, Route> = {
root: { id: 'root', file: 'root.tsx', path: '' },
a: { id: 'a', parentId: 'root', file: 'routes/shared.tsx', path: 'a' },
b: { id: 'b', parentId: 'root', file: 'routes/shared.tsx', path: 'b' },
};

try {
const { webRouteEntries } = createClassicWebRouteEntries({
appDirectory: appDir,
isBuild: true,
routes,
splitRouteModules: true,
});

expect(Object.keys(webRouteEntries).sort()).toEqual([
'root',
'routes/shared',
'routes/shared-client-loader',
]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
Loading