1import { PathConfig, PathConfigMap, validatePathConfig } from '@react-navigation/core';
2import type { NavigationState, PartialState, Route } from '@react-navigation/routers';
3import Constants from 'expo-constants';
4import * as queryString from 'query-string';
5
6import { matchDeepDynamicRouteName, matchDynamicName, matchGroupName } from '../matchers';
7
8type Options<ParamList extends object> = {
9  initialRouteName?: string;
10  screens: PathConfigMap<ParamList>;
11};
12
13export type State = NavigationState | Omit<PartialState<NavigationState>, 'stale'>;
14
15type StringifyConfig = Record<string, (value: any) => string>;
16
17type ConfigItem = {
18  pattern?: string;
19  stringify?: StringifyConfig;
20  screens?: Record<string, ConfigItem>;
21  // Used as fallback for groups
22  initialRouteName?: string;
23};
24
25type CustomRoute = Route<string> & {
26  state?: State;
27};
28
29const DEFAULT_SCREENS: PathConfigMap<object> = {};
30
31const getActiveRoute = (state: State): { name: string; params?: object } => {
32  const route =
33    typeof state.index === 'number'
34      ? state.routes[state.index]
35      : state.routes[state.routes.length - 1];
36
37  if (route.state) {
38    return getActiveRoute(route.state);
39  }
40
41  if (route && isInvalidParams(route.params)) {
42    return getActiveRoute(createFakeState(route.params));
43  }
44
45  return route;
46};
47
48function createFakeState(params: StateAsParams) {
49  return {
50    stale: false,
51    type: 'UNKNOWN',
52    key: 'UNKNOWN',
53    index: 0,
54    routeNames: [],
55    routes: [
56      {
57        key: 'UNKNOWN',
58        name: params.screen,
59        params: params.params,
60        path: params.path,
61      },
62    ],
63  };
64}
65
66function segmentMatchesConvention(segment: string): boolean {
67  return (
68    segment === 'index' ||
69    matchDynamicName(segment) != null ||
70    matchGroupName(segment) != null ||
71    matchDeepDynamicRouteName(segment) != null
72  );
73}
74
75function encodeURIComponentPreservingBrackets(str: string) {
76  return encodeURIComponent(str).replace(/%5B/g, '[').replace(/%5D/g, ']');
77}
78
79/**
80 * Utility to serialize a navigation state object to a path string.
81 *
82 * @example
83 * ```js
84 * getPathFromState(
85 *   {
86 *     routes: [
87 *       {
88 *         name: 'Chat',
89 *         params: { author: 'Jane', id: 42 },
90 *       },
91 *     ],
92 *   },
93 *   {
94 *     screens: {
95 *       Chat: {
96 *         path: 'chat/:author/:id',
97 *         stringify: { author: author => author.toLowerCase() }
98 *       }
99 *     }
100 *   }
101 * )
102 * ```
103 *
104 * @param state Navigation state to serialize.
105 * @param options Extra options to fine-tune how to serialize the path.
106 * @returns Path representing the state, e.g. /foo/bar?count=42.
107 */
108export default function getPathFromState<ParamList extends object>(
109  state: State,
110  _options?: Options<ParamList> & {
111    preserveGroups?: boolean;
112    preserveDynamicRoutes?: boolean;
113  }
114): string {
115  return getPathDataFromState(state, _options).path;
116}
117
118export function getPathDataFromState<ParamList extends object>(
119  state: State,
120  _options: Options<ParamList> & {
121    preserveGroups?: boolean;
122    preserveDynamicRoutes?: boolean;
123  } = { screens: DEFAULT_SCREENS }
124) {
125  if (state == null) {
126    throw Error("Got 'undefined' for the navigation state. You must pass a valid state object.");
127  }
128
129  const { preserveGroups, preserveDynamicRoutes, ...options } = _options;
130
131  validatePathConfig(options);
132
133  // Expo Router disallows usage without a linking config.
134  if (Object.is(options.screens, DEFAULT_SCREENS)) {
135    throw Error("You must pass a 'screens' object to 'getPathFromState' to generate a path.");
136  }
137
138  return getPathFromResolvedState(
139    JSON.parse(JSON.stringify(state)),
140    // Create a normalized configs object which will be easier to use
141    createNormalizedConfigs(options.screens),
142    { preserveGroups, preserveDynamicRoutes }
143  );
144}
145
146function processParamsWithUserSettings(configItem: ConfigItem, params: Record<string, any>) {
147  const stringify = configItem?.stringify;
148
149  return Object.fromEntries(
150    Object.entries(params).map(([key, value]) => [
151      key,
152      // TODO: Strip nullish values here.
153      stringify?.[key]
154        ? stringify[key](value)
155        : // Preserve rest params
156        Array.isArray(value)
157        ? value
158        : String(value),
159    ])
160  );
161}
162
163export function deepEqual(a: any, b: any) {
164  if (a === b) {
165    return true;
166  }
167
168  if (Array.isArray(a) && Array.isArray(b)) {
169    if (a.length !== b.length) {
170      return false;
171    }
172
173    for (let i = 0; i < a.length; i++) {
174      if (!deepEqual(a[i], b[i])) {
175        return false;
176      }
177    }
178
179    return true;
180  }
181
182  if (typeof a === 'object' && typeof b === 'object') {
183    const keysA = Object.keys(a);
184    const keysB = Object.keys(b);
185
186    if (keysA.length !== keysB.length) {
187      return false;
188    }
189
190    for (const key of keysA) {
191      if (!deepEqual(a[key], b[key])) {
192        return false;
193      }
194    }
195
196    return true;
197  }
198
199  return false;
200}
201
202function walkConfigItems(
203  route: CustomRoute,
204  focusedRoute: {
205    name: string;
206    params?: object;
207  },
208  configs: Record<string, ConfigItem>,
209  {
210    preserveDynamicRoutes,
211  }: {
212    preserveDynamicRoutes?: boolean;
213  }
214) {
215  // NOTE(EvanBacon): Fill in current route using state that was passed as params.
216  if (!route.state && isInvalidParams(route.params)) {
217    route.state = createFakeState(route.params);
218  }
219
220  let pattern: string | null = null;
221  let focusedParams: Record<string, any> | undefined;
222
223  const collectedParams: Record<string, any> = {};
224
225  while (route.name in configs) {
226    const configItem = configs[route.name];
227    const inputPattern = configItem.pattern;
228
229    if (inputPattern == null) {
230      // This should never happen in Expo Router.
231      throw new Error('Unexpected: No pattern found for route ' + route.name);
232    }
233    pattern = inputPattern;
234
235    if (route.params) {
236      const params = processParamsWithUserSettings(configItem, route.params);
237      // TODO: Does this need to be a null check?
238      if (pattern) {
239        Object.assign(collectedParams, params);
240      }
241      if (deepEqual(focusedRoute, route)) {
242        if (preserveDynamicRoutes) {
243          focusedParams = params;
244        } else {
245          // If this is the focused route, keep the params for later use
246          // We save it here since it's been stringified already
247          focusedParams = getParamsWithConventionsCollapsed({
248            params,
249            pattern,
250            routeName: route.name,
251          });
252        }
253      }
254    }
255
256    if (!route.state && isInvalidParams(route.params)) {
257      route.state = createFakeState(route.params);
258    }
259
260    // If there is no `screens` property or no nested state, we return pattern
261    if (!configItem.screens || route.state === undefined) {
262      if (
263        configItem.initialRouteName &&
264        configItem.screens &&
265        configItem.initialRouteName in configItem.screens &&
266        configItem.screens[configItem.initialRouteName]?.pattern
267      ) {
268        const initialRouteConfig = configItem.screens[configItem.initialRouteName];
269
270        // NOTE(EvanBacon): Big hack to support initial route changes in tab bars.
271        pattern = initialRouteConfig.pattern!;
272        if (focusedParams) {
273          if (!preserveDynamicRoutes) {
274            // If this is the focused route, keep the params for later use
275            // We save it here since it's been stringified already
276            focusedParams = getParamsWithConventionsCollapsed({
277              params: focusedParams,
278              pattern,
279              routeName: route.name,
280            });
281          }
282        }
283      }
284      break;
285    }
286
287    const index = route.state.index ?? route.state.routes.length - 1;
288
289    const nextRoute = route.state.routes[index];
290    const nestedScreens = configItem.screens;
291
292    // if there is config for next route name, we go deeper
293    if (nestedScreens && nextRoute.name in nestedScreens) {
294      route = nextRoute as CustomRoute;
295      configs = nestedScreens;
296    } else {
297      // If not, there is no sense in going deeper in config
298      break;
299    }
300  }
301
302  if (pattern == null) {
303    throw new Error(
304      `No pattern found for route "${route.name}". Options are: ${Object.keys(configs).join(', ')}.`
305    );
306  }
307
308  if (pattern && !focusedParams && focusedRoute.params) {
309    if (preserveDynamicRoutes) {
310      focusedParams = focusedRoute.params;
311    } else {
312      // If this is the focused route, keep the params for later use
313      // We save it here since it's been stringified already
314      focusedParams = getParamsWithConventionsCollapsed({
315        params: focusedRoute.params,
316        pattern,
317        routeName: route.name,
318      });
319    }
320    Object.assign(focusedParams, collectedParams);
321  }
322
323  return {
324    pattern,
325    nextRoute: route,
326    focusedParams,
327    params: collectedParams,
328  };
329}
330
331function getPathFromResolvedState(
332  state: State,
333  configs: Record<string, ConfigItem>,
334  {
335    preserveGroups,
336    preserveDynamicRoutes,
337  }: { preserveGroups?: boolean; preserveDynamicRoutes?: boolean }
338) {
339  let path = '';
340  let current: State = state;
341
342  const allParams: Record<string, any> = {};
343
344  while (current) {
345    path += '/';
346
347    // Make mutable copies to ensure we don't leak state outside of the function.
348    const route = current.routes[current.index ?? 0] as CustomRoute;
349
350    // NOTE(EvanBacon): Fill in current route using state that was passed as params.
351    // if (isInvalidParams(route.params)) {
352    if (!route.state && isInvalidParams(route.params)) {
353      route.state = createFakeState(route.params);
354    }
355
356    const { pattern, params, nextRoute, focusedParams } = walkConfigItems(
357      route,
358      getActiveRoute(current),
359      { ...configs },
360      { preserveDynamicRoutes }
361    );
362
363    Object.assign(allParams, params);
364
365    path += getPathWithConventionsCollapsed({
366      pattern,
367      routePath: nextRoute.path,
368      params: allParams,
369      initialRouteName: configs[nextRoute.name]?.initialRouteName,
370      preserveGroups,
371      preserveDynamicRoutes,
372    });
373
374    if (
375      nextRoute.state &&
376      // NOTE(EvanBacon): The upstream implementation allows for sending in synthetic states (states that weren't generated by `getStateFromPath`)
377      // and any invalid routes will simply be ignored.
378      // Because of this, we need to check if the next route is valid before continuing, otherwise our more strict
379      // implementation will throw an error.
380      configs[nextRoute.state.routes?.[nextRoute.state?.index ?? 0]?.name]
381    ) {
382      // Continue looping with the next state if available.
383      current = nextRoute.state;
384    } else {
385      // Finished crawling state.
386
387      // Check for query params before exiting.
388      if (focusedParams) {
389        for (const param in focusedParams) {
390          // TODO: This is not good. We shouldn't squat strings named "undefined".
391          if (focusedParams[param] === 'undefined') {
392            // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
393            delete focusedParams[param];
394          }
395        }
396
397        const query = queryString.stringify(focusedParams, { sort: false });
398        if (query) {
399          path += `?${query}`;
400        }
401      }
402      break;
403    }
404  }
405
406  return { path: appendBasePath(basicSanitizePath(path)), params: decodeParams(allParams) };
407}
408
409function decodeParams(params: Record<string, string>) {
410  const parsed: Record<string, any> = {};
411
412  for (const [key, value] of Object.entries(params)) {
413    parsed[key] = decodeURIComponent(value);
414  }
415
416  return parsed;
417}
418
419function getPathWithConventionsCollapsed({
420  pattern,
421  routePath,
422  params,
423  preserveGroups,
424  preserveDynamicRoutes,
425  initialRouteName,
426}: {
427  pattern: string;
428  routePath?: string;
429  params: Record<string, any>;
430  preserveGroups?: boolean;
431  preserveDynamicRoutes?: boolean;
432  initialRouteName?: string;
433}) {
434  const segments = pattern.split('/');
435  return segments
436    .map((p, i) => {
437      const name = getParamName(p);
438
439      // We don't know what to show for wildcard patterns
440      // Showing the route name seems ok, though whatever we show here will be incorrect
441      // Since the page doesn't actually exist
442      if (p.startsWith('*')) {
443        if (preserveDynamicRoutes) {
444          return `[...${name}]`;
445        }
446        if (params[name]) {
447          if (Array.isArray(params[name])) {
448            return params[name].join('/');
449          }
450          return params[name];
451        }
452        if (i === 0) {
453          // This can occur when a wildcard matches all routes and the given path was `/`.
454          return routePath;
455        }
456        // remove existing segments from route.path and return it
457        // this is used for nested wildcard routes. Without this, the path would add
458        // all nested segments to the beginning of the wildcard route.
459        return routePath
460          ?.split('/')
461          .slice(i + 1)
462          .join('/');
463      }
464
465      // If the path has a pattern for a param, put the param in the path
466      if (p.startsWith(':')) {
467        if (preserveDynamicRoutes) {
468          return `[${name}]`;
469        }
470        // Optional params without value assigned in route.params should be ignored
471        return params[name];
472      }
473
474      if (!preserveGroups && matchGroupName(p) != null) {
475        // When the last part is a group it could be a shared URL
476        // if the route has an initialRouteName defined, then we should
477        // use that as the component path as we can assume it will be shown.
478        if (segments.length - 1 === i) {
479          if (initialRouteName) {
480            // Return an empty string if the init route is ambiguous.
481            if (segmentMatchesConvention(initialRouteName)) {
482              return '';
483            }
484            return encodeURIComponentPreservingBrackets(initialRouteName);
485          }
486        }
487        return '';
488      }
489      // Preserve dynamic syntax for rehydration
490      return encodeURIComponentPreservingBrackets(p);
491    })
492    .map((v) => v ?? '')
493    .join('/');
494}
495
496/** Given a set of query params and a pattern with possible conventions, collapse the conventions and return the remaining params. */
497function getParamsWithConventionsCollapsed({
498  pattern,
499  routeName,
500  params,
501}: {
502  pattern: string;
503  /** Route name is required for matching the wildcard route. This is specific to Expo Router. */
504  routeName: string;
505  params: object;
506}): Record<string, string> {
507  const processedParams: Record<string, string> = { ...params };
508
509  // Remove the params present in the pattern since we'll only use the rest for query string
510
511  const segments = pattern.split('/');
512
513  // Dynamic Routes
514  segments
515    .filter((segment) => segment.startsWith(':'))
516    .forEach((segment) => {
517      const name = getParamName(segment);
518      delete processedParams[name];
519    });
520
521  // Deep Dynamic Routes
522  if (segments.some((segment) => segment.startsWith('*'))) {
523    // NOTE(EvanBacon): Drop the param name matching the wildcard route name -- this is specific to Expo Router.
524    const name = matchDeepDynamicRouteName(routeName) ?? routeName;
525    delete processedParams[name];
526  }
527
528  return processedParams;
529}
530
531// Remove multiple as well as trailing slashes
532function basicSanitizePath(path: string) {
533  // Remove duplicate slashes like `foo//bar` -> `foo/bar`
534  const simplifiedPath = path.replace(/\/+/g, '/');
535  if (simplifiedPath.length <= 1) {
536    return simplifiedPath;
537  }
538  // Remove trailing slash like `foo/bar/` -> `foo/bar`
539  return simplifiedPath.replace(/\/$/, '');
540}
541
542type StateAsParams = {
543  initial: boolean;
544  path?: string;
545  screen: string;
546  params: Record<string, any>;
547};
548
549// TODO: Make StackRouter not do this...
550// Detect if the params came from StackRouter using `params` to pass around internal state.
551function isInvalidParams(params?: Record<string, any>): params is StateAsParams {
552  if (!params) {
553    return false;
554  }
555
556  if ('params' in params && typeof params.params === 'object' && !!params.params) {
557    return true;
558  }
559
560  return (
561    'initial' in params &&
562    typeof params.initial === 'boolean' &&
563    // "path" in params &&
564    'screen' in params
565  );
566}
567
568const getParamName = (pattern: string) => pattern.replace(/^[:*]/, '').replace(/\?$/, '');
569
570const joinPaths = (...paths: string[]): string =>
571  ([] as string[])
572    .concat(...paths.map((p) => p.split('/')))
573    .filter(Boolean)
574    .join('/');
575
576const createConfigItem = (
577  config: PathConfig<object> | string,
578  parentPattern?: string
579): ConfigItem => {
580  if (typeof config === 'string') {
581    // If a string is specified as the value of the key(e.g. Foo: '/path'), use it as the pattern
582    const pattern = parentPattern ? joinPaths(parentPattern, config) : config;
583
584    return { pattern };
585  }
586
587  if (config.exact && config.path === undefined) {
588    throw new Error(
589      "A 'path' needs to be specified when specifying 'exact: true'. If you don't want this screen in the URL, specify it as empty string, e.g. `path: ''`."
590    );
591  }
592
593  // If an object is specified as the value (e.g. Foo: { ... }),
594  // It can have `path` property and `screens` prop which has nested configs
595  const pattern =
596    config.exact !== true ? joinPaths(parentPattern || '', config.path || '') : config.path || '';
597
598  const screens = config.screens ? createNormalizedConfigs(config.screens, pattern) : undefined;
599
600  return {
601    // Normalize pattern to remove any leading, trailing slashes, duplicate slashes etc.
602    pattern: pattern?.split('/').filter(Boolean).join('/'),
603    stringify: config.stringify,
604    screens,
605    initialRouteName: config.initialRouteName,
606  };
607};
608
609const createNormalizedConfigs = (
610  options: PathConfigMap<object>,
611  pattern?: string
612): Record<string, ConfigItem> =>
613  Object.fromEntries(
614    Object.entries(options).map(([name, c]) => [name, createConfigItem(c, pattern)])
615  );
616
617export function appendBasePath(
618  path: string,
619  assetPrefix: string | undefined = Constants.expoConfig?.experiments?.basePath
620) {
621  if (process.env.NODE_ENV !== 'development') {
622    if (assetPrefix) {
623      return `/${assetPrefix.replace(/^\/+/, '').replace(/\/$/, '')}${path}`;
624    }
625  }
626  return path;
627}
628