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