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)/blog/[post].tsx',
296              dynamic: [{ deep: false, name: 'post' }],
297              route: '(index)/blog/[post]',
298            },
299            {
300              children: [],
301              contextKey: './(app)/(about)/blog/[post].tsx',
302              dynamic: [{ deep: false, name: 'post' }],
303              route: '(about)/blog/[post]',
304            },
305          ],
306          contextKey: './(app)/_layout.tsx',
307          dynamic: null,
308          initialRouteName: undefined,
309          route: '(app)',
310        },
311      ],
312      contextKey: './_layout.tsx',
313      dynamic: null,
314      generated: true,
315      route: '',
316    });
317
318    expect(dropFunctions(await loadStaticParamsAsync(route))).toEqual({
319      children: [
320        {
321          children: [
322            {
323              children: [],
324              contextKey: './(app)/(index)/blog/[post].tsx',
325              dynamic: [{ deep: false, name: 'post' }],
326              route: '(index)/blog/[post]',
327            },
328            {
329              children: [],
330              contextKey: './(app)/(index)/blog/123.tsx',
331              dynamic: null,
332              route: '(index)/blog/123',
333            },
334            {
335              children: [],
336              contextKey: './(app)/(index)/blog/abc.tsx',
337              dynamic: null,
338              route: '(index)/blog/abc',
339            },
340            {
341              children: [],
342              contextKey: './(app)/(about)/blog/[post].tsx',
343              dynamic: [{ deep: false, name: 'post' }],
344              route: '(about)/blog/[post]',
345            },
346            {
347              children: [],
348              contextKey: './(app)/(about)/blog/123.tsx',
349              dynamic: null,
350              route: '(about)/blog/123',
351            },
352            {
353              children: [],
354              contextKey: './(app)/(about)/blog/abc.tsx',
355              dynamic: null,
356              route: '(about)/blog/abc',
357            },
358          ],
359          contextKey: './(app)/_layout.tsx',
360          dynamic: null,
361          initialRouteName: undefined,
362          route: '(app)',
363        },
364      ],
365      contextKey: './_layout.tsx',
366      dynamic: null,
367      generated: true,
368      route: '',
369    });
370  });
371
372  it(`generateStaticParams with nested dynamic segments`, async () => {
373    const ctx = createMockContextModule({
374      './post/[post].tsx': {
375        default() {},
376        async generateStaticParams() {
377          return [{ post: '123' }];
378        },
379      },
380      './a/[b]/c/[d]/[e].tsx': {
381        default() {},
382        async generateStaticParams() {
383          return [{ b: 'b', d: 'd', e: 'e' }];
384        },
385      },
386    });
387
388    const route = getExactRoutes(ctx)!;
389
390    expect(dropFunctions(route)).toEqual({
391      children: [
392        {
393          children: [],
394          contextKey: './post/[post].tsx',
395          dynamic: [{ deep: false, name: 'post' }],
396          route: 'post/[post]',
397        },
398        {
399          children: [],
400          contextKey: './a/[b]/c/[d]/[e].tsx',
401          dynamic: [
402            {
403              deep: false,
404              name: 'b',
405            },
406            {
407              deep: false,
408              name: 'd',
409            },
410            {
411              deep: false,
412              name: 'e',
413            },
414          ],
415          route: 'a/[b]/c/[d]/[e]',
416        },
417      ],
418      contextKey: './_layout.tsx',
419      dynamic: null,
420      generated: true,
421      route: '',
422    });
423
424    expect(dropFunctions(await loadStaticParamsAsync(route))).toEqual({
425      children: [
426        {
427          children: [],
428          contextKey: './post/[post].tsx',
429          dynamic: [{ deep: false, name: 'post' }],
430          route: 'post/[post]',
431        },
432        {
433          children: [],
434          contextKey: './post/123.tsx',
435          dynamic: null,
436          route: 'post/123',
437        },
438        {
439          children: [],
440          contextKey: './a/[b]/c/[d]/[e].tsx',
441          dynamic: [
442            {
443              deep: false,
444              name: 'b',
445            },
446            {
447              deep: false,
448              name: 'd',
449            },
450            {
451              deep: false,
452              name: 'e',
453            },
454          ],
455          route: 'a/[b]/c/[d]/[e]',
456        },
457        {
458          children: [],
459          contextKey: './a/b/c/d/e.tsx',
460          dynamic: null,
461          route: 'a/b/c/d/e',
462        },
463      ],
464      contextKey: './_layout.tsx',
465      dynamic: null,
466      generated: true,
467      route: '',
468    });
469  });
470
471  it(`generateStaticParams throws when deep dynamic segments return invalid type`, async () => {
472    const loadWithParam = (params) =>
473      loadStaticParamsAsync(
474        getExactRoutes(
475          createMockContextModule({
476            './post/[...post].tsx': {
477              default() {},
478              generateStaticParams() {
479                return params;
480              },
481            },
482          })
483        )!
484      );
485
486    // Passes
487    await loadWithParam([{ post: '123' }]);
488    await loadWithParam([{ post: '123/456' }]);
489    await loadWithParam([{ post: ['123/456', '123'] }]);
490    await loadWithParam([{ post: ['123', '123'] }]);
491    await loadWithParam([{ post: ['123', '/'] }]);
492    await loadWithParam([{ post: [123, '/', '432'] }]);
493
494    await expect(loadWithParam([{ post: ['/'] }])).rejects.toThrowErrorMatchingInlineSnapshot(
495      `"generateStaticParams() for route "./post/[...post].tsx" expected param "post" not to be empty while parsing "/"."`
496    );
497    await expect(loadWithParam([{ post: '' }])).rejects.toThrowErrorMatchingInlineSnapshot(
498      `"generateStaticParams() for route "./post/[...post].tsx" expected param "post" not to be empty while parsing ""."`
499    );
500    await expect(
501      loadWithParam([{ post: ['', '/', ''] }])
502    ).rejects.toThrowErrorMatchingInlineSnapshot(
503      `"generateStaticParams() for route "./post/[...post].tsx" expected param "post" not to be empty while parsing "/"."`
504    );
505    await expect(loadWithParam([{ post: null }])).rejects.toThrowErrorMatchingInlineSnapshot(
506      `"generateStaticParams() must return an array of params that match the dynamic route. Received {"post":null}"`
507    );
508    await expect(loadWithParam([{ post: false }])).rejects.toThrowErrorMatchingInlineSnapshot(
509      `"generateStaticParams() for route "./post/[...post].tsx" expected param "post" to be of type string, instead found "boolean" while parsing "false"."`
510    );
511  });
512
513  it(`generateStaticParams throws when dynamic segments return invalid type`, async () => {
514    const ctx = createMockContextModule({
515      './post/[post].tsx': {
516        default() {},
517        generateStaticParams() {
518          return [{ post: ['123'] }];
519        },
520      },
521    });
522    const route = getExactRoutes(ctx)!;
523    await expect(loadStaticParamsAsync(route)).rejects.toThrowErrorMatchingInlineSnapshot(
524      `"generateStaticParams() for route "./post/[post].tsx" expected param "post" to be of type string, instead found "object" while parsing "123"."`
525    );
526  });
527
528  it(`generateStaticParams throws when dynamic segments return invalid format (multiple slugs)`, async () => {
529    const ctx = createMockContextModule({
530      './post/[post].tsx': {
531        default() {},
532        generateStaticParams() {
533          return [{ post: '123/abc' }];
534        },
535      },
536    });
537    const route = getExactRoutes(ctx)!;
538    await expect(loadStaticParamsAsync(route)).rejects.toThrowErrorMatchingInlineSnapshot(
539      `"generateStaticParams() for route "./post/[post].tsx" expected param "post" to not contain "/" (multiple segments) while parsing "123/abc"."`
540    );
541  });
542
543  it(`generateStaticParams throws when dynamic segments return empty string`, async () => {
544    await expect(
545      loadStaticParamsAsync(
546        getExactRoutes(
547          createMockContextModule({
548            './post/[post].tsx': {
549              default() {},
550              generateStaticParams() {
551                return [{ post: '/' }];
552              },
553            },
554          })
555        )!
556      )
557    ).rejects.toThrowErrorMatchingInlineSnapshot(
558      `"generateStaticParams() for route "./post/[post].tsx" expected param "post" not to be empty while parsing "/"."`
559    );
560    await expect(
561      loadStaticParamsAsync(
562        getExactRoutes(
563          createMockContextModule({
564            './post/[post].tsx': {
565              default() {},
566              generateStaticParams() {
567                return [{ post: '' }];
568              },
569            },
570          })
571        )!
572      )
573    ).rejects.toThrowErrorMatchingInlineSnapshot(
574      `"generateStaticParams() for route "./post/[post].tsx" expected param "post" not to be empty while parsing ""."`
575    );
576  });
577
578  it(`generateStaticParams allows when dynamic segments return a single slug with a benign slash`, async () => {
579    const ctx = createMockContextModule({
580      './post/[post].tsx': {
581        default() {},
582        generateStaticParams() {
583          return [{ post: '123/' }, { post: '/123' }];
584        },
585      },
586    });
587    // doesn't throw
588    await loadStaticParamsAsync(getExactRoutes(ctx)!);
589  });
590});
591