1import { RouteNode } from '../Route';
2import { getExactRoutes } from '../getRoutes';
3import { loadStaticParamsAsync } from '../loadStaticParamsAsync';
4import { RequireContext } from '../types';
5
6function createMockContextModule(map: Record<string, Record<string, any>> = {}) {
7  const contextModule = jest.fn((key) => map[key]);
8
9  Object.defineProperty(contextModule, 'keys', {
10    value: () => Object.keys(map),
11  });
12
13  return contextModule as unknown as RequireContext;
14}
15
16function dropFunctions({ loadRoute, ...node }: RouteNode) {
17  return {
18    ...node,
19    children: node.children.map(dropFunctions),
20  };
21}
22
23describe(loadStaticParamsAsync, () => {
24  it(`evaluates a single dynamic param`, async () => {
25    const route = getExactRoutes(
26      createMockContextModule({
27        './[color].tsx': {
28          default() {},
29          unstable_settings: { initialRouteName: 'index' },
30          generateStaticParams() {
31            return ['red', 'blue'].map((color) => ({ color }));
32          },
33        },
34      })
35    )!;
36
37    expect(dropFunctions(route)).toEqual({
38      children: [
39        {
40          children: [],
41          contextKey: './[color].tsx',
42          dynamic: [{ deep: false, name: 'color' }],
43          route: '[color]',
44        },
45      ],
46      contextKey: './_layout.tsx',
47      dynamic: null,
48      generated: true,
49      route: '',
50    });
51
52    const r = await loadStaticParamsAsync(route);
53
54    expect(dropFunctions(r)).toEqual({
55      children: [
56        {
57          children: [],
58          contextKey: './[color].tsx',
59          dynamic: [{ deep: false, name: 'color' }],
60          route: '[color]',
61        },
62        { children: [], contextKey: './red.tsx', dynamic: null, route: 'red' },
63        {
64          children: [],
65          contextKey: './blue.tsx',
66          dynamic: null,
67          route: 'blue',
68        },
69      ],
70      contextKey: './_layout.tsx',
71      dynamic: null,
72      generated: true,
73      route: '',
74    });
75  });
76
77  it(`evaluates with nested dynamic routes`, async () => {
78    const ctx = createMockContextModule({
79      './_layout.tsx': { default() {} },
80      './[color]/[shape].tsx': {
81        default() {},
82        async generateStaticParams({ params }) {
83          return ['square', 'triangle'].map((shape) => ({
84            ...params,
85            shape,
86          }));
87        },
88      },
89      './[color]/_layout.tsx': {
90        default() {},
91        generateStaticParams() {
92          return ['red', 'blue'].map((color) => ({ color }));
93        },
94      },
95    });
96    const route = getExactRoutes(ctx);
97
98    expect(dropFunctions(route!)).toEqual({
99      children: [
100        {
101          children: [
102            {
103              children: [],
104              contextKey: './[color]/[shape].tsx',
105              dynamic: [{ deep: false, name: 'shape' }],
106              route: '[shape]',
107            },
108          ],
109          contextKey: './[color]/_layout.tsx',
110          dynamic: [{ deep: false, name: 'color' }],
111          initialRouteName: undefined,
112          route: '[color]',
113        },
114      ],
115      contextKey: './_layout.tsx',
116      dynamic: null,
117      initialRouteName: undefined,
118      route: '',
119    });
120
121    const r = await loadStaticParamsAsync(route!);
122
123    expect(dropFunctions(r)).toEqual({
124      children: [
125        {
126          children: [
127            {
128              children: [],
129              contextKey: './[color]/[shape].tsx',
130              dynamic: [{ deep: false, name: 'shape' }],
131              route: '[shape]',
132            },
133            {
134              children: [],
135              contextKey: './[color]/square.tsx',
136              dynamic: null,
137              route: 'square',
138            },
139            {
140              children: [],
141              contextKey: './[color]/triangle.tsx',
142              dynamic: null,
143              route: 'triangle',
144            },
145          ],
146          contextKey: './[color]/_layout.tsx',
147          dynamic: [{ deep: false, name: 'color' }],
148          initialRouteName: undefined,
149          route: '[color]',
150        },
151        {
152          children: [
153            {
154              children: [],
155              contextKey: './[color]/[shape].tsx',
156              dynamic: [{ deep: false, name: 'shape' }],
157              route: '[shape]',
158            },
159            {
160              children: [],
161              contextKey: './[color]/square.tsx',
162              dynamic: null,
163              route: 'square',
164            },
165            {
166              children: [],
167              contextKey: './[color]/triangle.tsx',
168              dynamic: null,
169              route: 'triangle',
170            },
171          ],
172          contextKey: './red/_layout.tsx',
173          dynamic: null,
174          initialRouteName: undefined,
175          route: 'red',
176        },
177        {
178          children: [
179            {
180              children: [],
181              contextKey: './[color]/[shape].tsx',
182              dynamic: [{ deep: false, name: 'shape' }],
183              route: '[shape]',
184            },
185            {
186              children: [],
187              contextKey: './[color]/square.tsx',
188              dynamic: null,
189              route: 'square',
190            },
191            {
192              children: [],
193              contextKey: './[color]/triangle.tsx',
194              dynamic: null,
195              route: 'triangle',
196            },
197          ],
198          contextKey: './blue/_layout.tsx',
199          dynamic: null,
200          initialRouteName: undefined,
201          route: 'blue',
202        },
203      ],
204      contextKey: './_layout.tsx',
205      dynamic: null,
206      initialRouteName: undefined,
207      route: '',
208    });
209  });
210
211  it(`throws when required parameter is missing`, async () => {
212    const routes = getExactRoutes(
213      createMockContextModule({
214        './post/[post].tsx': {
215          default() {},
216          generateStaticParams() {
217            return [{}];
218          },
219        },
220      })
221    )!;
222    await expect(loadStaticParamsAsync(routes)).rejects.toThrowErrorMatchingInlineSnapshot(
223      `"generateStaticParams() must return an array of params that match the dynamic route. Received {}"`
224    );
225  });
226
227  it(`evaluates with nested deep dynamic segments`, async () => {
228    const ctx = createMockContextModule({
229      './post/[...post].tsx': {
230        default() {},
231        async generateStaticParams() {
232          return [{ post: ['123', '456'] }];
233        },
234      },
235    });
236
237    const route = getExactRoutes(ctx)!;
238
239    expect(dropFunctions(route)).toEqual({
240      children: [
241        {
242          children: [],
243          contextKey: './post/[...post].tsx',
244          dynamic: [{ deep: true, name: 'post' }],
245          route: 'post/[...post]',
246        },
247      ],
248      contextKey: './_layout.tsx',
249      dynamic: null,
250      generated: true,
251      route: '',
252    });
253
254    expect(dropFunctions(await loadStaticParamsAsync(route))).toEqual({
255      children: [
256        {
257          children: [],
258          contextKey: './post/[...post].tsx',
259          dynamic: [{ deep: true, name: 'post' }],
260          route: 'post/[...post]',
261        },
262        {
263          children: [],
264          contextKey: './post/123/456.tsx',
265          dynamic: null,
266          route: 'post/123/456',
267        },
268      ],
269      contextKey: './_layout.tsx',
270      dynamic: null,
271      generated: true,
272      route: '',
273    });
274  });
275
276  it(`evaluates with nested clone syntax`, async () => {
277    const ctx = createMockContextModule({
278      './(app)/_layout.tsx': { default() {} },
279      './(app)/(index,about)/blog/[post].tsx': {
280        default() {},
281        async generateStaticParams() {
282          return [{ post: '123' }, { post: 'abc' }];
283        },
284      },
285    });
286
287    const route = getExactRoutes(ctx)!;
288
289    expect(dropFunctions(route)).toEqual({
290      children: [
291        {
292          children: [
293            {
294              children: [],
295              contextKey: './(app)/(index,about)/blog/[post].tsx',
296              dynamic: [{ deep: false, name: 'post' }],
297              route: '(index,about)/blog/[post]',
298            },
299          ],
300          contextKey: './(app)/_layout.tsx',
301          dynamic: null,
302          initialRouteName: undefined,
303          route: '(app)',
304        },
305      ],
306      contextKey: './_layout.tsx',
307      dynamic: null,
308      generated: true,
309      route: '',
310    });
311
312    expect(dropFunctions(await loadStaticParamsAsync(route))).toEqual({
313      children: [
314        {
315          children: [
316            {
317              children: [],
318              contextKey: './(app)/(index,about)/blog/[post].tsx',
319              dynamic: [{ deep: false, name: 'post' }],
320              route: '(index,about)/blog/[post]',
321            },
322            {
323              children: [],
324              contextKey: './(app)/(index,about)/blog/123.tsx',
325              dynamic: null,
326              route: '(index,about)/blog/123',
327            },
328            {
329              children: [],
330              contextKey: './(app)/(index,about)/blog/abc.tsx',
331              dynamic: null,
332              route: '(index,about)/blog/abc',
333            },
334          ],
335          contextKey: './(app)/_layout.tsx',
336          dynamic: null,
337          initialRouteName: undefined,
338          route: '(app)',
339        },
340      ],
341      contextKey: './_layout.tsx',
342      dynamic: null,
343      generated: true,
344      route: '',
345    });
346  });
347
348  it(`generateStaticParams with nested dynamic segments`, async () => {
349    const ctx = createMockContextModule({
350      './post/[post].tsx': {
351        default() {},
352        async generateStaticParams() {
353          return [{ post: '123' }];
354        },
355      },
356      './a/[b]/c/[d]/[e].tsx': {
357        default() {},
358        async generateStaticParams() {
359          return [{ b: 'b', d: 'd', e: 'e' }];
360        },
361      },
362    });
363
364    const route = getExactRoutes(ctx)!;
365
366    expect(dropFunctions(route)).toEqual({
367      children: [
368        {
369          children: [],
370          contextKey: './post/[post].tsx',
371          dynamic: [{ deep: false, name: 'post' }],
372          route: 'post/[post]',
373        },
374        {
375          children: [],
376          contextKey: './a/[b]/c/[d]/[e].tsx',
377          dynamic: [
378            {
379              deep: false,
380              name: 'b',
381            },
382            {
383              deep: false,
384              name: 'd',
385            },
386            {
387              deep: false,
388              name: 'e',
389            },
390          ],
391          route: 'a/[b]/c/[d]/[e]',
392        },
393      ],
394      contextKey: './_layout.tsx',
395      dynamic: null,
396      generated: true,
397      route: '',
398    });
399
400    expect(dropFunctions(await loadStaticParamsAsync(route))).toEqual({
401      children: [
402        {
403          children: [],
404          contextKey: './post/[post].tsx',
405          dynamic: [{ deep: false, name: 'post' }],
406          route: 'post/[post]',
407        },
408        {
409          children: [],
410          contextKey: './post/123.tsx',
411          dynamic: null,
412          route: 'post/123',
413        },
414        {
415          children: [],
416          contextKey: './a/[b]/c/[d]/[e].tsx',
417          dynamic: [
418            {
419              deep: false,
420              name: 'b',
421            },
422            {
423              deep: false,
424              name: 'd',
425            },
426            {
427              deep: false,
428              name: 'e',
429            },
430          ],
431          route: 'a/[b]/c/[d]/[e]',
432        },
433        {
434          children: [],
435          contextKey: './a/b/c/d/e.tsx',
436          dynamic: null,
437          route: 'a/b/c/d/e',
438        },
439      ],
440      contextKey: './_layout.tsx',
441      dynamic: null,
442      generated: true,
443      route: '',
444    });
445  });
446
447  it(`generateStaticParams throws when deep dynamic segments return invalid type`, async () => {
448    const loadWithParam = (params) =>
449      loadStaticParamsAsync(
450        getExactRoutes(
451          createMockContextModule({
452            './post/[...post].tsx': {
453              default() {},
454              generateStaticParams() {
455                return params;
456              },
457            },
458          })
459        )!
460      );
461
462    // Passes
463    await loadWithParam([{ post: '123' }]);
464    await loadWithParam([{ post: '123/456' }]);
465    await loadWithParam([{ post: ['123/456', '123'] }]);
466    await loadWithParam([{ post: ['123', '123'] }]);
467    await loadWithParam([{ post: ['123', '/'] }]);
468    await loadWithParam([{ post: [123, '/', '432'] }]);
469
470    await expect(loadWithParam([{ post: ['/'] }])).rejects.toThrowErrorMatchingInlineSnapshot(
471      `"generateStaticParams() for route "./post/[...post].tsx" expected param "post" not to be empty while parsing "/"."`
472    );
473    await expect(loadWithParam([{ post: '' }])).rejects.toThrowErrorMatchingInlineSnapshot(
474      `"generateStaticParams() for route "./post/[...post].tsx" expected param "post" not to be empty while parsing ""."`
475    );
476    await expect(
477      loadWithParam([{ post: ['', '/', ''] }])
478    ).rejects.toThrowErrorMatchingInlineSnapshot(
479      `"generateStaticParams() for route "./post/[...post].tsx" expected param "post" not to be empty while parsing "/"."`
480    );
481    await expect(loadWithParam([{ post: null }])).rejects.toThrowErrorMatchingInlineSnapshot(
482      `"generateStaticParams() must return an array of params that match the dynamic route. Received {"post":null}"`
483    );
484    await expect(loadWithParam([{ post: false }])).rejects.toThrowErrorMatchingInlineSnapshot(
485      `"generateStaticParams() for route "./post/[...post].tsx" expected param "post" to be of type string, instead found "boolean" while parsing "false"."`
486    );
487  });
488
489  it(`generateStaticParams throws when dynamic segments return invalid type`, async () => {
490    const ctx = createMockContextModule({
491      './post/[post].tsx': {
492        default() {},
493        generateStaticParams() {
494          return [{ post: ['123'] }];
495        },
496      },
497    });
498    const route = getExactRoutes(ctx)!;
499    await expect(loadStaticParamsAsync(route)).rejects.toThrowErrorMatchingInlineSnapshot(
500      `"generateStaticParams() for route "./post/[post].tsx" expected param "post" to be of type string, instead found "object" while parsing "123"."`
501    );
502  });
503
504  it(`generateStaticParams throws when dynamic segments return invalid format (multiple slugs)`, async () => {
505    const ctx = createMockContextModule({
506      './post/[post].tsx': {
507        default() {},
508        generateStaticParams() {
509          return [{ post: '123/abc' }];
510        },
511      },
512    });
513    const route = getExactRoutes(ctx)!;
514    await expect(loadStaticParamsAsync(route)).rejects.toThrowErrorMatchingInlineSnapshot(
515      `"generateStaticParams() for route "./post/[post].tsx" expected param "post" to not contain "/" (multiple segments) while parsing "123/abc"."`
516    );
517  });
518
519  it(`generateStaticParams throws when dynamic segments return empty string`, async () => {
520    await expect(
521      loadStaticParamsAsync(
522        getExactRoutes(
523          createMockContextModule({
524            './post/[post].tsx': {
525              default() {},
526              generateStaticParams() {
527                return [{ post: '/' }];
528              },
529            },
530          })
531        )!
532      )
533    ).rejects.toThrowErrorMatchingInlineSnapshot(
534      `"generateStaticParams() for route "./post/[post].tsx" expected param "post" not to be empty while parsing "/"."`
535    );
536    await expect(
537      loadStaticParamsAsync(
538        getExactRoutes(
539          createMockContextModule({
540            './post/[post].tsx': {
541              default() {},
542              generateStaticParams() {
543                return [{ post: '' }];
544              },
545            },
546          })
547        )!
548      )
549    ).rejects.toThrowErrorMatchingInlineSnapshot(
550      `"generateStaticParams() for route "./post/[post].tsx" expected param "post" not to be empty while parsing ""."`
551    );
552  });
553
554  it(`generateStaticParams allows when dynamic segments return a single slug with a benign slash`, async () => {
555    const ctx = createMockContextModule({
556      './post/[post].tsx': {
557        default() {},
558        generateStaticParams() {
559          return [{ post: '123/' }, { post: '/123' }];
560        },
561      },
562    });
563    // doesn't throw
564    await loadStaticParamsAsync(getExactRoutes(ctx)!);
565  });
566});
567