Skip to content

Commit 8098741

Browse files
authored
fix(core): fix codegen routesChunkName possible hash collision (#10727)
1 parent 1777b14 commit 8098741

File tree

2 files changed

+104
-16
lines changed

2 files changed

+104
-16
lines changed

packages/docusaurus/src/server/codegen/__tests__/codegenRoutes.test.ts

+61-11
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
import {fromPartial} from '@total-typescript/shoehorn';
89
import {
910
generateRoutesCode,
1011
genChunkName,
1112
generateRoutePropFilename,
1213
} from '../codegenRoutes';
1314
import type {RouteConfig} from '@docusaurus/types';
1415

16+
function route(routeConfig: Partial<RouteConfig>): RouteConfig {
17+
return fromPartial(routeConfig);
18+
}
19+
1520
describe('generateRoutePropFilename', () => {
1621
it('generate filename based on route path', () => {
1722
expect(
@@ -206,9 +211,7 @@ describe('loadRoutes', () => {
206211
},
207212
],
208213
};
209-
expect(
210-
generateRoutesCode([nestedRouteConfig], '/', 'ignore'),
211-
).toMatchSnapshot();
214+
expect(generateRoutesCode([nestedRouteConfig])).toMatchSnapshot();
212215
});
213216

214217
it('loads flat route config', () => {
@@ -243,17 +246,15 @@ describe('loadRoutes', () => {
243246
],
244247
},
245248
};
246-
expect(
247-
generateRoutesCode([flatRouteConfig], '/', 'ignore'),
248-
).toMatchSnapshot();
249+
expect(generateRoutesCode([flatRouteConfig])).toMatchSnapshot();
249250
});
250251

251252
it('rejects invalid route config', () => {
252253
const routeConfigWithoutPath = {
253254
component: 'hello/world.js',
254255
} as RouteConfig;
255256

256-
expect(() => generateRoutesCode([routeConfigWithoutPath], '/', 'ignore'))
257+
expect(() => generateRoutesCode([routeConfigWithoutPath]))
257258
.toThrowErrorMatchingInlineSnapshot(`
258259
"Invalid route config: path must be a string and component is required.
259260
{"component":"hello/world.js"}"
@@ -263,9 +264,8 @@ describe('loadRoutes', () => {
263264
path: '/hello/world',
264265
} as RouteConfig;
265266

266-
expect(() =>
267-
generateRoutesCode([routeConfigWithoutComponent], '/', 'ignore'),
268-
).toThrowErrorMatchingInlineSnapshot(`
267+
expect(() => generateRoutesCode([routeConfigWithoutComponent]))
268+
.toThrowErrorMatchingInlineSnapshot(`
269269
"Invalid route config: path must be a string and component is required.
270270
{"path":"/hello/world"}"
271271
`);
@@ -277,6 +277,56 @@ describe('loadRoutes', () => {
277277
component: 'hello/world.js',
278278
} as RouteConfig;
279279

280-
expect(generateRoutesCode([routeConfig], '/', 'ignore')).toMatchSnapshot();
280+
expect(generateRoutesCode([routeConfig])).toMatchSnapshot();
281+
});
282+
283+
it('generates an entry for each route and handle hash collisions', () => {
284+
// See bug https://github.com/facebook/docusaurus/issues/10718#issuecomment-2507635907
285+
const routeConfigs = [
286+
route({
287+
path: '/docs',
288+
component: '@theme/Root',
289+
routes: [
290+
route({
291+
path: '/docs',
292+
component: '@theme/Version',
293+
children: [],
294+
}),
295+
],
296+
}),
297+
route({
298+
path: '/docs',
299+
component: '@theme/Root',
300+
routes: [
301+
route({
302+
path: '/docs',
303+
component: '@theme/Version',
304+
children: [],
305+
}),
306+
],
307+
}),
308+
];
309+
310+
const result = generateRoutesCode(routeConfigs);
311+
312+
// We absolutely want to have 2 entries here, even if routes are the same
313+
// One should not override the other
314+
expect(Object.keys(result.routesChunkNames)).toHaveLength(4);
315+
expect(result.routesChunkNames).toMatchInlineSnapshot(`
316+
{
317+
"/docs-611": {
318+
"__comp": "__comp---theme-version-6-f-8-19f",
319+
},
320+
"/docs-96a": {
321+
"__comp": "__comp---theme-root-1-dd-d3a",
322+
},
323+
"/docs-d3d": {
324+
"__comp": "__comp---theme-version-6-f-8-19f",
325+
},
326+
"/docs-e4f": {
327+
"__comp": "__comp---theme-root-1-dd-d3a",
328+
},
329+
}
330+
`);
281331
});
282332
});

packages/docusaurus/src/server/codegen/codegenRoutes.ts

+43-5
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,12 @@ function genChunkNames(
194194
* config node, it returns the node's serialized form, and mutates `registry`,
195195
* `routesPaths`, and `routesChunkNames` accordingly.
196196
*/
197-
function genRouteCode(routeConfig: RouteConfig, res: RoutesCode): string {
197+
function genRouteCode(
198+
routeConfig: RouteConfig,
199+
res: RoutesCode,
200+
index: number,
201+
level: number,
202+
): string {
198203
const {
199204
path: routePath,
200205
component,
@@ -216,8 +221,39 @@ ${JSON.stringify(routeConfig)}`,
216221
);
217222
}
218223

219-
const routeHash = simpleHash(JSON.stringify(routeConfig), 3);
220-
res.routesChunkNames[`${routePath}-${routeHash}`] = {
224+
// Because 2 routes with the same path could lead to hash collisions
225+
// See https://github.com/facebook/docusaurus/issues/10718#issuecomment-2498516394
226+
function generateUniqueRouteKey(): {
227+
routeKey: string;
228+
routeHash: string;
229+
} {
230+
const hashes = [
231+
// // OG algo to keep former snapshots
232+
() => simpleHash(JSON.stringify(routeConfig), 3),
233+
// Other attempts, not ideal but good enough
234+
// Technically we could use Math.random() here but it's annoying for tests
235+
() => simpleHash(`${level}${index}`, 3),
236+
() => simpleHash(JSON.stringify(routeConfig), 4),
237+
() => simpleHash(`${level}${index}`, 4),
238+
];
239+
240+
for (const tryHash of hashes) {
241+
const routeHash = tryHash();
242+
const routeKey = `${routePath}-${routeHash}`;
243+
if (!res.routesChunkNames[routeKey]) {
244+
return {routeKey, routeHash};
245+
}
246+
}
247+
throw new Error(
248+
`Docusaurus couldn't generate a unique hash for route ${routeConfig.path} (level=${level} - index=${index}).
249+
This is a bug, please report it here!
250+
https://github.com/facebook/docusaurus/issues/10718`,
251+
);
252+
}
253+
254+
const {routeKey, routeHash} = generateUniqueRouteKey();
255+
256+
res.routesChunkNames[routeKey] = {
221257
// Avoid clash with a prop called "component"
222258
...genChunkNames({__comp: component}, 'component', component, res),
223259
...(context &&
@@ -228,7 +264,9 @@ ${JSON.stringify(routeConfig)}`,
228264
return serializeRouteConfig({
229265
routePath: routePath.replace(/'/g, "\\'"),
230266
routeHash,
231-
subroutesCodeStrings: subroutes?.map((r) => genRouteCode(r, res)),
267+
subroutesCodeStrings: subroutes?.map((r, i) =>
268+
genRouteCode(r, res, i, level + 1),
269+
),
232270
exact,
233271
attributes,
234272
});
@@ -253,7 +291,7 @@ export function generateRoutesCode(routeConfigs: RouteConfig[]): RoutesCode {
253291

254292
// `genRouteCode` would mutate `res`
255293
const routeConfigSerialized = routeConfigs
256-
.map((r) => genRouteCode(r, res))
294+
.map((r, i) => genRouteCode(r, res, i, 0))
257295
.join(',\n');
258296

259297
res.routesConfig = `import React from 'react';

0 commit comments

Comments
 (0)