xref: /expo/packages/expo-router/src/getRoutes.ts (revision 34a1b52d)
1import type { DynamicConvention, RouteNode } from './Route';
2import EXPO_ROUTER_IMPORT_MODE from './import-mode';
3import {
4  getNameFromFilePath,
5  matchDeepDynamicRouteName,
6  matchDynamicName,
7  matchGroupName,
8  removeSupportedExtensions,
9  stripGroupSegmentsFromPath,
10  stripInvisibleSegmentsFromPath,
11} from './matchers';
12import type { RequireContext } from './types';
13
14export type FileNode = Pick<RouteNode, 'contextKey' | 'loadRoute'> & {
15  /** Like `(tab)/index` */
16  normalizedName: string;
17};
18
19type TreeNode = {
20  name: string;
21  children: TreeNode[];
22  parents: string[];
23  /** null when there is no file in a folder. */
24  node: FileNode | null;
25};
26
27type Options = {
28  ignore?: RegExp[];
29  preserveApiRoutes?: boolean;
30  ignoreRequireErrors?: boolean;
31};
32
33/** Convert a flat map of file nodes into a nested tree of files. */
34export function getRecursiveTree(files: FileNode[]): TreeNode {
35  const tree = {
36    name: '',
37    children: [],
38    parents: [],
39    node: null,
40  };
41
42  for (const file of files) {
43    // ['(tab)', 'settings', '[...another]']
44    const parts = file.normalizedName.split('/');
45    let currentNode: TreeNode = tree;
46    for (let i = 0; i < parts.length; i++) {
47      const part = parts[i];
48
49      if (i === parts.length - 1 && part === '_layout') {
50        if (currentNode.node) {
51          const overwritten = currentNode.node.contextKey;
52          throw new Error(
53            `Higher priority Layout Route "${file.contextKey}" overriding redundant Layout Route "${overwritten}". Remove the Layout Route "${overwritten}" to fix this.`
54          );
55        }
56        continue;
57      }
58
59      const existing = currentNode.children.find((item) => item.name === part);
60      if (existing) {
61        currentNode = existing;
62      } else {
63        const newNode: TreeNode = {
64          name: part,
65          children: [],
66          parents: [...currentNode.parents, currentNode.name],
67          node: null,
68        };
69        currentNode.children.push(newNode);
70        currentNode = newNode;
71      }
72    }
73    currentNode.node = file;
74  }
75
76  if (process.env.NODE_ENV !== 'production') {
77    assertDeprecatedFormat(tree);
78  }
79
80  return tree;
81}
82
83function assertDeprecatedFormat(tree: TreeNode) {
84  for (const child of tree.children) {
85    if (child.node && child.children.length && !child.node.normalizedName.endsWith('_layout')) {
86      const ext = child.node.contextKey.split('.').pop();
87      throw new Error(
88        `Using deprecated Layout Route format: Move \`./app/${child.node.normalizedName}.${ext}\` to \`./app/${child.node.normalizedName}/_layout.${ext}\``
89      );
90    }
91    assertDeprecatedFormat(child);
92  }
93}
94
95function getTreeNodesAsRouteNodes(nodes: TreeNode[]): RouteNode[] {
96  return nodes
97    .map((node) => treeNodeToRouteNode(node))
98    .flat()
99    .filter(Boolean) as RouteNode[];
100}
101
102export function generateDynamicFromSegment(name: string): DynamicConvention | null {
103  const deepDynamicName = matchDeepDynamicRouteName(name);
104  const dynamicName = deepDynamicName ?? matchDynamicName(name);
105
106  return dynamicName ? { name: dynamicName, deep: !!deepDynamicName } : null;
107}
108
109export function generateDynamic(name: string): RouteNode['dynamic'] {
110  const description = name
111    .split('/')
112    .map((segment) => generateDynamicFromSegment(segment))
113    .filter(Boolean) as DynamicConvention[];
114  return description.length === 0 ? null : description;
115}
116
117function collapseRouteSegments(route: string) {
118  return stripGroupSegmentsFromPath(route.replace(/\/index$/, ''));
119}
120
121/**
122 * Given a route node and a name representing the group name,
123 * find the nearest child matching the name.
124 *
125 * Doesn't support slashes in the name.
126 * Routes like `explore/(something)/index` will be matched against `explore`.
127 *
128 */
129function getDefaultInitialRoute(node: RouteNode, name: string) {
130  return node.children.find((node) => collapseRouteSegments(node.route) === name);
131}
132
133function applyDefaultInitialRouteName(node: RouteNode): RouteNode {
134  const groupName = matchGroupName(node.route);
135  if (!node.children?.length) {
136    return node;
137  }
138
139  // Guess at the initial route based on the group name.
140  // TODO(EvanBacon): Perhaps we should attempt to warn when the group doesn't match any child routes.
141  let initialRouteName = groupName ? getDefaultInitialRoute(node, groupName)?.route : undefined;
142  const loaded = node.loadRoute();
143
144  if (loaded?.unstable_settings) {
145    // Allow unstable_settings={ initialRouteName: '...' } to override the default initial route name.
146    initialRouteName = loaded.unstable_settings.initialRouteName ?? initialRouteName;
147
148    if (groupName) {
149      // Allow unstable_settings={ 'custom': { initialRouteName: '...' } } to override the less specific initial route name.
150      const groupSpecificInitialRouteName = loaded.unstable_settings?.[groupName]?.initialRouteName;
151
152      initialRouteName = groupSpecificInitialRouteName ?? initialRouteName;
153    }
154  }
155
156  return {
157    ...node,
158    initialRouteName,
159  };
160}
161
162function folderNodeToRouteNode({ name, children }: TreeNode): RouteNode[] | null {
163  // Empty folder, skip it.
164  if (!children.length) {
165    return null;
166  }
167
168  // When there's a directory, but no layout route file (with valid export), the child routes won't be grouped.
169  // This pushes all children into the nearest layout route.
170  return getTreeNodesAsRouteNodes(
171    children.map((child) => {
172      return {
173        ...child,
174        name: [name, child.name].filter(Boolean).join('/'),
175      };
176    })
177  );
178}
179
180function fileNodeToRouteNode(tree: TreeNode): RouteNode[] | null {
181  const { name, node, children } = tree;
182
183  if (!node) throw new Error('node must be defined');
184
185  const dynamic = generateDynamic(name);
186
187  const clones = extrapolateGroupRoutes(name, node.contextKey);
188  clones.delete(name);
189
190  const output = {
191    loadRoute: node.loadRoute,
192    route: name,
193    contextKey: node.contextKey,
194    children: getTreeNodesAsRouteNodes(children),
195    dynamic,
196  };
197
198  if (clones.size) {
199    return [...clones].map((clone) =>
200      applyDefaultInitialRouteName({
201        ...output,
202        contextKey: node.contextKey.replace(output.route, clone),
203        route: clone,
204      })
205    );
206  }
207
208  return [
209    applyDefaultInitialRouteName({
210      loadRoute: node.loadRoute,
211      route: name,
212      contextKey: node.contextKey,
213      children: getTreeNodesAsRouteNodes(children),
214      dynamic,
215    }),
216  ];
217}
218
219function extrapolateGroupRoutes(
220  route: string,
221  contextKey: string,
222  routes: Set<string> = new Set()
223): Set<string> {
224  const match = matchGroupName(route);
225
226  if (!match) {
227    routes.add(route);
228    return routes;
229  }
230
231  const groups = match?.split(',');
232  const groupsSet = new Set(groups);
233
234  if (groupsSet.size !== groups.length) {
235    throw new Error(
236      `Array syntax cannot contain duplicate group name "${groups}" in "${contextKey}".`
237    );
238  }
239
240  if (groups.length === 1) {
241    routes.add(route);
242    return routes;
243  }
244
245  for (const group of groups) {
246    extrapolateGroupRoutes(route.replace(match, group.trim()), contextKey, routes);
247  }
248
249  return routes;
250}
251
252function treeNodeToRouteNode(tree: TreeNode): RouteNode[] | null {
253  if (tree.node) {
254    return fileNodeToRouteNode(tree);
255  }
256
257  return folderNodeToRouteNode(tree);
258}
259
260function contextModuleToFileNodes(
261  contextModule: RequireContext,
262  options: Options = {},
263  files: string[] = contextModule.keys()
264): FileNode[] {
265  const nodes = files.map((key) => {
266    // In development, check if the file exports a default component
267    // this helps keep things snappy when creating files. In production we load all screens lazily.
268    try {
269      if (process.env.NODE_ENV === 'development') {
270        // If the user has set the `EXPO_ROUTER_IMPORT_MODE` to `sync` then we should
271        // filter the missing routes.
272        if (EXPO_ROUTER_IMPORT_MODE === 'sync') {
273          const isApi = key.match(/\+api\.[jt]sx?$/);
274          if (!isApi && !contextModule(key)?.default) {
275            return null;
276          }
277        }
278      }
279      const node: FileNode = {
280        loadRoute() {
281          if (options.ignoreRequireErrors) {
282            try {
283              return contextModule(key);
284            } catch {
285              return {};
286            }
287          } else {
288            return contextModule(key);
289          }
290        },
291        normalizedName: getNameFromFilePath(key),
292        contextKey: key,
293      };
294
295      return node;
296    } catch (error) {
297      // Probably this won't stop metro from freaking out but it's worth a try.
298      console.warn('Error loading route "' + key + '"', error);
299    }
300    return null;
301  });
302
303  return nodes.filter(Boolean) as FileNode[];
304}
305
306function hasCustomRootLayoutNode(routes: RouteNode[]) {
307  if (routes.length !== 1) {
308    return false;
309  }
310  // This could either be the root _layout or an app with a single file.
311  const route = routes[0];
312
313  if (route.route === '' && route.contextKey.match(/^\.\/_layout\.([jt]sx?)$/)) {
314    return true;
315  }
316  return false;
317}
318
319function treeNodesToRootRoute(treeNode: TreeNode): RouteNode | null {
320  const routes = treeNodeToRouteNode(treeNode);
321  return withOptionalRootLayout(routes);
322}
323
324function processKeys(files: string[], options: Options): string[] {
325  const { ignore } = options;
326
327  return files.filter((file) => {
328    return !ignore?.some((pattern) => pattern.test(file));
329  });
330}
331
332/**
333 * Asserts if the require.context has files that share the same name but have different extensions. Exposed for testing.
334 * @private
335 */
336export function assertDuplicateRoutes(filenames: string[]) {
337  if (process.env.NODE_ENV === 'production') {
338    return;
339  }
340
341  const duplicates = filenames
342    .map((filename) => removeSupportedExtensions(filename))
343    .reduce(
344      (acc, filename) => {
345        acc[filename] = acc[filename] ? acc[filename] + 1 : 1;
346        return acc;
347      },
348      {} as Record<string, number>
349    );
350
351  Object.entries(duplicates).forEach(([filename, count]) => {
352    if (count > 1) {
353      throw new Error(`Multiple files match the route name "${filename}".`);
354    }
355  });
356}
357
358/** Given a Metro context module, return an array of nested routes. */
359export function getRoutes(contextModule: RequireContext, options?: Options): RouteNode | null {
360  const route = getExactRoutes(contextModule, options);
361
362  // If there is no route, return an empty route.
363  if (!route) {
364    return null;
365  }
366
367  appendSitemapRoute(route);
368
369  // Auto add not found route if it doesn't exist
370  appendUnmatchedRoute(route);
371
372  return route;
373}
374
375export async function getRoutesAsync(
376  contextModule: RequireContext,
377  options?: Options
378): Promise<RouteNode | null> {
379  const route = await getExactRoutesAsync(contextModule, options);
380  if (!route) {
381    return null;
382  }
383
384  appendSitemapRoute(route);
385
386  // Auto add not found route if it doesn't exist
387  appendUnmatchedRoute(route);
388
389  return route;
390}
391
392function getIgnoreList(options?: Options) {
393  const ignore: RegExp[] = [/^\.\/\+html\.[tj]sx?$/, ...(options?.ignore ?? [])];
394  if (options?.preserveApiRoutes !== true) {
395    ignore.push(/\+api\.[tj]sx?$/);
396  }
397  return ignore;
398}
399
400/** Get routes without unmatched or sitemap. */
401export function getExactRoutes(contextModule: RequireContext, options?: Options): RouteNode | null {
402  const treeNodes = contextModuleToTree(contextModule, options);
403  const route = treeNodesToRootRoute(treeNodes);
404  return route || null;
405}
406
407function contextModuleToTree(contextModule: RequireContext, options?: Options) {
408  const allowed = processKeys(contextModule.keys(), {
409    ...options,
410    ignore: getIgnoreList(options),
411  });
412  assertDuplicateRoutes(allowed);
413  const files = contextModuleToFileNodes(contextModule, options, allowed);
414  return getRecursiveTree(files);
415}
416
417export async function getExactRoutesAsync(
418  contextModule: RequireContext,
419  options?: Options
420): Promise<RouteNode | null> {
421  const treeNodes = contextModuleToTree(contextModule, options);
422  const route = treeNodesToRootRoute(treeNodes);
423  return route || null;
424}
425
426function appendSitemapRoute(routes: RouteNode) {
427  if (
428    !routes.children.length ||
429    // Allow overriding the sitemap route
430    routes.children.some((route) => route.route === '_sitemap')
431  ) {
432    return routes;
433  }
434  routes.children.push({
435    loadRoute() {
436      const { Sitemap, getNavOptions } = require('./views/Sitemap');
437      return { default: Sitemap, getNavOptions };
438    },
439    route: '_sitemap',
440    contextKey: './_sitemap.tsx',
441    generated: true,
442    internal: true,
443    dynamic: null,
444    children: [],
445  });
446  return routes;
447}
448
449function appendUnmatchedRoute(routes: RouteNode) {
450  // Auto add not found route if it doesn't exist
451  const userDefinedDynamicRoute = getUserDefinedDeepDynamicRoute(routes);
452  if (!userDefinedDynamicRoute) {
453    routes.children.push({
454      loadRoute() {
455        return { default: require('./views/Unmatched').Unmatched };
456      },
457      route: '[...404]',
458      contextKey: './[...404].tsx',
459      dynamic: [{ name: '404', deep: true }],
460      children: [],
461      generated: true,
462      internal: true,
463    });
464  }
465  return routes;
466}
467
468/**
469 * Exposed for testing.
470 * @returns a top-level deep dynamic route if it exists, otherwise null.
471 */
472export function getUserDefinedDeepDynamicRoute(routes: RouteNode): RouteNode | null {
473  // Auto add not found route if it doesn't exist
474  for (const route of routes.children ?? []) {
475    if (route.generated) continue;
476    const opaqueRoute = stripInvisibleSegmentsFromPath(route.route);
477    const isDeepDynamic = matchDeepDynamicRouteName(opaqueRoute);
478    if (isDeepDynamic) {
479      return route;
480    }
481    // Recurse through group routes
482    if (matchGroupName(route.route)) {
483      const child = getUserDefinedDeepDynamicRoute(route);
484      if (child) {
485        return child;
486      }
487    }
488  }
489  return null;
490}
491
492function withOptionalRootLayout(routes: RouteNode[] | null): RouteNode | null {
493  if (!routes?.length) {
494    return null;
495  }
496
497  if (hasCustomRootLayoutNode(routes)) {
498    return routes[0];
499  }
500
501  return {
502    loadRoute: () => ({
503      default: (require('./views/Navigator') as typeof import('./views/Navigator'))
504        .DefaultNavigator,
505    }),
506    // Generate a fake file name for the directory
507    contextKey: './_layout.tsx',
508    route: '',
509    generated: true,
510    dynamic: null,
511    children: routes,
512  };
513}
514