UNPKG

15.6 kBTypeScriptView Raw
1import type {
2 InitialState,
3 NavigationState,
4 PartialState,
5} from '@react-navigation/routers';
6import escape from 'escape-string-regexp';
7import * as queryString from 'query-string';
8
9import findFocusedRoute from './findFocusedRoute';
10import type { PathConfigMap } from './types';
11import validatePathConfig from './validatePathConfig';
12
13type Options<ParamList extends {}> = {
14 initialRouteName?: string;
15 screens: PathConfigMap<ParamList>;
16};
17
18type ParseConfig = Record<string, (value: string) => any>;
19
20type RouteConfig = {
21 screen: string;
22 regex?: RegExp;
23 path: string;
24 pattern: string;
25 routeNames: string[];
26 parse?: ParseConfig;
27};
28
29type InitialRouteConfig = {
30 initialRouteName: string;
31 parentScreens: string[];
32};
33
34type ResultState = PartialState<NavigationState> & {
35 state?: ResultState;
36};
37
38type ParsedRoute = {
39 name: string;
40 path?: string;
41 params?: Record<string, any> | undefined;
42};
43
44/**
45 * Utility to parse a path string to initial state object accepted by the container.
46 * This is useful for deep linking when we need to handle the incoming URL.
47 *
48 * @example
49 * ```js
50 * getStateFromPath(
51 * '/chat/jane/42',
52 * {
53 * screens: {
54 * Chat: {
55 * path: 'chat/:author/:id',
56 * parse: { id: Number }
57 * }
58 * }
59 * }
60 * )
61 * ```
62 * @param path Path string to parse and convert, e.g. /foo/bar?count=42.
63 * @param options Extra options to fine-tune how to parse the path.
64 */
65export default function getStateFromPath<ParamList extends {}>(
66 path: string,
67 options?: Options<ParamList>
68): ResultState | undefined {
69 if (options) {
70 validatePathConfig(options);
71 }
72
73 let initialRoutes: InitialRouteConfig[] = [];
74
75 if (options?.initialRouteName) {
76 initialRoutes.push({
77 initialRouteName: options.initialRouteName,
78 parentScreens: [],
79 });
80 }
81
82 const screens = options?.screens;
83
84 let remaining = path
85 .replace(/\/+/g, '/') // Replace multiple slash (//) with single ones
86 .replace(/^\//, '') // Remove extra leading slash
87 .replace(/\?.*$/, ''); // Remove query params which we will handle later
88
89 // Make sure there is a trailing slash
90 remaining = remaining.endsWith('/') ? remaining : `${remaining}/`;
91
92 if (screens === undefined) {
93 // When no config is specified, use the path segments as route names
94 const routes = remaining
95 .split('/')
96 .filter(Boolean)
97 .map((segment) => {
98 const name = decodeURIComponent(segment);
99 return { name };
100 });
101
102 if (routes.length) {
103 return createNestedStateObject(path, routes, initialRoutes);
104 }
105
106 return undefined;
107 }
108
109 // Create a normalized configs array which will be easier to use
110 const configs = ([] as RouteConfig[])
111 .concat(
112 ...Object.keys(screens).map((key) =>
113 createNormalizedConfigs(
114 key,
115 screens as PathConfigMap<object>,
116 [],
117 initialRoutes,
118 []
119 )
120 )
121 )
122 .sort((a, b) => {
123 // Sort config so that:
124 // - the most exhaustive ones are always at the beginning
125 // - patterns with wildcard are always at the end
126
127 // If 2 patterns are same, move the one with less route names up
128 // This is an error state, so it's only useful for consistent error messages
129 if (a.pattern === b.pattern) {
130 return b.routeNames.join('>').localeCompare(a.routeNames.join('>'));
131 }
132
133 // If one of the patterns starts with the other, it's more exhaustive
134 // So move it up
135 if (a.pattern.startsWith(b.pattern)) {
136 return -1;
137 }
138
139 if (b.pattern.startsWith(a.pattern)) {
140 return 1;
141 }
142
143 const aParts = a.pattern.split('/');
144 const bParts = b.pattern.split('/');
145
146 for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
147 // if b is longer, b get higher priority
148 if (aParts[i] == null) {
149 return 1;
150 }
151 // if a is longer, a get higher priority
152 if (bParts[i] == null) {
153 return -1;
154 }
155 const aWildCard = aParts[i] === '*' || aParts[i].startsWith(':');
156 const bWildCard = bParts[i] === '*' || bParts[i].startsWith(':');
157 // if both are wildcard we compare next component
158 if (aWildCard && bWildCard) {
159 continue;
160 }
161 // if only a is wild card, b get higher priority
162 if (aWildCard) {
163 return 1;
164 }
165 // if only b is wild card, a get higher priority
166 if (bWildCard) {
167 return -1;
168 }
169 }
170 return bParts.length - aParts.length;
171 });
172
173 // Check for duplicate patterns in the config
174 configs.reduce<Record<string, RouteConfig>>((acc, config) => {
175 if (acc[config.pattern]) {
176 const a = acc[config.pattern].routeNames;
177 const b = config.routeNames;
178
179 // It's not a problem if the path string omitted from a inner most screen
180 // For example, it's ok if a path resolves to `A > B > C` or `A > B`
181 const intersects =
182 a.length > b.length
183 ? b.every((it, i) => a[i] === it)
184 : a.every((it, i) => b[i] === it);
185
186 if (!intersects) {
187 throw new Error(
188 `Found conflicting screens with the same pattern. The pattern '${
189 config.pattern
190 }' resolves to both '${a.join(' > ')}' and '${b.join(
191 ' > '
192 )}'. Patterns must be unique and cannot resolve to more than one screen.`
193 );
194 }
195 }
196
197 return Object.assign(acc, {
198 [config.pattern]: config,
199 });
200 }, {});
201
202 if (remaining === '/') {
203 // We need to add special handling of empty path so navigation to empty path also works
204 // When handling empty path, we should only look at the root level config
205 const match = configs.find(
206 (config) =>
207 config.path === '' &&
208 config.routeNames.every(
209 // Make sure that none of the parent configs have a non-empty path defined
210 (name) => !configs.find((c) => c.screen === name)?.path
211 )
212 );
213
214 if (match) {
215 return createNestedStateObject(
216 path,
217 match.routeNames.map((name) => ({ name })),
218 initialRoutes,
219 configs
220 );
221 }
222
223 return undefined;
224 }
225
226 let result: PartialState<NavigationState> | undefined;
227 let current: PartialState<NavigationState> | undefined;
228
229 // We match the whole path against the regex instead of segments
230 // This makes sure matches such as wildcard will catch any unmatched routes, even if nested
231 const { routes, remainingPath } = matchAgainstConfigs(
232 remaining,
233 configs.map((c) => ({
234 ...c,
235 // Add `$` to the regex to make sure it matches till end of the path and not just beginning
236 regex: c.regex ? new RegExp(c.regex.source + '$') : undefined,
237 }))
238 );
239
240 if (routes !== undefined) {
241 // This will always be empty if full path matched
242 current = createNestedStateObject(path, routes, initialRoutes, configs);
243 remaining = remainingPath;
244 result = current;
245 }
246
247 if (current == null || result == null) {
248 return undefined;
249 }
250
251 return result;
252}
253
254const joinPaths = (...paths: string[]): string =>
255 ([] as string[])
256 .concat(...paths.map((p) => p.split('/')))
257 .filter(Boolean)
258 .join('/');
259
260const matchAgainstConfigs = (remaining: string, configs: RouteConfig[]) => {
261 let routes: ParsedRoute[] | undefined;
262 let remainingPath = remaining;
263
264 // Go through all configs, and see if the next path segment matches our regex
265 for (const config of configs) {
266 if (!config.regex) {
267 continue;
268 }
269
270 const match = remainingPath.match(config.regex);
271
272 // If our regex matches, we need to extract params from the path
273 if (match) {
274 const matchedParams = config.pattern
275 ?.split('/')
276 .filter((p) => p.startsWith(':'))
277 .reduce<Record<string, any>>(
278 (acc, p, i) =>
279 Object.assign(acc, {
280 // The param segments appear every second item starting from 2 in the regex match result
281 [p]: match![(i + 1) * 2].replace(/\//, ''),
282 }),
283 {}
284 );
285
286 routes = config.routeNames.map((name) => {
287 const config = configs.find((c) => c.screen === name);
288 const params = config?.path
289 ?.split('/')
290 .filter((p) => p.startsWith(':'))
291 .reduce<Record<string, any>>((acc, p) => {
292 const value = matchedParams[p];
293
294 if (value) {
295 const key = p.replace(/^:/, '').replace(/\?$/, '');
296 acc[key] = config.parse?.[key] ? config.parse[key](value) : value;
297 }
298
299 return acc;
300 }, {});
301
302 if (params && Object.keys(params).length) {
303 return { name, params };
304 }
305
306 return { name };
307 });
308
309 remainingPath = remainingPath.replace(match[1], '');
310
311 break;
312 }
313 }
314
315 return { routes, remainingPath };
316};
317
318const createNormalizedConfigs = (
319 screen: string,
320 routeConfig: PathConfigMap<object>,
321 routeNames: string[] = [],
322 initials: InitialRouteConfig[],
323 parentScreens: string[],
324 parentPattern?: string
325): RouteConfig[] => {
326 const configs: RouteConfig[] = [];
327
328 routeNames.push(screen);
329
330 parentScreens.push(screen);
331
332 // @ts-expect-error: we can't strongly typecheck this for now
333 const config = routeConfig[screen];
334
335 if (typeof config === 'string') {
336 // If a string is specified as the value of the key(e.g. Foo: '/path'), use it as the pattern
337 const pattern = parentPattern ? joinPaths(parentPattern, config) : config;
338
339 configs.push(createConfigItem(screen, routeNames, pattern, config));
340 } else if (typeof config === 'object') {
341 let pattern: string | undefined;
342
343 // if an object is specified as the value (e.g. Foo: { ... }),
344 // it can have `path` property and
345 // it could have `screens` prop which has nested configs
346 if (typeof config.path === 'string') {
347 if (config.exact && config.path === undefined) {
348 throw new Error(
349 "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: ''`."
350 );
351 }
352
353 pattern =
354 config.exact !== true
355 ? joinPaths(parentPattern || '', config.path || '')
356 : config.path || '';
357
358 configs.push(
359 createConfigItem(
360 screen,
361 routeNames,
362 pattern!,
363 config.path,
364 config.parse
365 )
366 );
367 }
368
369 if (config.screens) {
370 // property `initialRouteName` without `screens` has no purpose
371 if (config.initialRouteName) {
372 initials.push({
373 initialRouteName: config.initialRouteName,
374 parentScreens,
375 });
376 }
377
378 Object.keys(config.screens).forEach((nestedConfig) => {
379 const result = createNormalizedConfigs(
380 nestedConfig,
381 config.screens as PathConfigMap<object>,
382 routeNames,
383 initials,
384 [...parentScreens],
385 pattern ?? parentPattern
386 );
387
388 configs.push(...result);
389 });
390 }
391 }
392
393 routeNames.pop();
394
395 return configs;
396};
397
398const createConfigItem = (
399 screen: string,
400 routeNames: string[],
401 pattern: string,
402 path: string,
403 parse?: ParseConfig
404): RouteConfig => {
405 // Normalize pattern to remove any leading, trailing slashes, duplicate slashes etc.
406 pattern = pattern.split('/').filter(Boolean).join('/');
407
408 const regex = pattern
409 ? new RegExp(
410 `^(${pattern
411 .split('/')
412 .map((it) => {
413 if (it.startsWith(':')) {
414 return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`;
415 }
416
417 return `${it === '*' ? '.*' : escape(it)}\\/`;
418 })
419 .join('')})`
420 )
421 : undefined;
422
423 return {
424 screen,
425 regex,
426 pattern,
427 path,
428 // The routeNames array is mutated, so copy it to keep the current state
429 routeNames: [...routeNames],
430 parse,
431 };
432};
433
434const findParseConfigForRoute = (
435 routeName: string,
436 flatConfig: RouteConfig[]
437): ParseConfig | undefined => {
438 for (const config of flatConfig) {
439 if (routeName === config.routeNames[config.routeNames.length - 1]) {
440 return config.parse;
441 }
442 }
443
444 return undefined;
445};
446
447// Try to find an initial route connected with the one passed
448const findInitialRoute = (
449 routeName: string,
450 parentScreens: string[],
451 initialRoutes: InitialRouteConfig[]
452): string | undefined => {
453 for (const config of initialRoutes) {
454 if (parentScreens.length === config.parentScreens.length) {
455 let sameParents = true;
456 for (let i = 0; i < parentScreens.length; i++) {
457 if (parentScreens[i].localeCompare(config.parentScreens[i]) !== 0) {
458 sameParents = false;
459 break;
460 }
461 }
462 if (sameParents) {
463 return routeName !== config.initialRouteName
464 ? config.initialRouteName
465 : undefined;
466 }
467 }
468 }
469 return undefined;
470};
471
472// returns state object with values depending on whether
473// it is the end of state and if there is initialRoute for this level
474const createStateObject = (
475 initialRoute: string | undefined,
476 route: ParsedRoute,
477 isEmpty: boolean
478): InitialState => {
479 if (isEmpty) {
480 if (initialRoute) {
481 return {
482 index: 1,
483 routes: [{ name: initialRoute }, route],
484 };
485 } else {
486 return {
487 routes: [route],
488 };
489 }
490 } else {
491 if (initialRoute) {
492 return {
493 index: 1,
494 routes: [{ name: initialRoute }, { ...route, state: { routes: [] } }],
495 };
496 } else {
497 return {
498 routes: [{ ...route, state: { routes: [] } }],
499 };
500 }
501 }
502};
503
504const createNestedStateObject = (
505 path: string,
506 routes: ParsedRoute[],
507 initialRoutes: InitialRouteConfig[],
508 flatConfig?: RouteConfig[]
509) => {
510 let state: InitialState;
511 let route = routes.shift() as ParsedRoute;
512 const parentScreens: string[] = [];
513
514 let initialRoute = findInitialRoute(route.name, parentScreens, initialRoutes);
515
516 parentScreens.push(route.name);
517
518 state = createStateObject(initialRoute, route, routes.length === 0);
519
520 if (routes.length > 0) {
521 let nestedState = state;
522
523 while ((route = routes.shift() as ParsedRoute)) {
524 initialRoute = findInitialRoute(route.name, parentScreens, initialRoutes);
525
526 const nestedStateIndex =
527 nestedState.index || nestedState.routes.length - 1;
528
529 nestedState.routes[nestedStateIndex].state = createStateObject(
530 initialRoute,
531 route,
532 routes.length === 0
533 );
534
535 if (routes.length > 0) {
536 nestedState = nestedState.routes[nestedStateIndex]
537 .state as InitialState;
538 }
539
540 parentScreens.push(route.name);
541 }
542 }
543
544 route = findFocusedRoute(state) as ParsedRoute;
545 route.path = path;
546
547 const params = parseQueryParams(
548 path,
549 flatConfig ? findParseConfigForRoute(route.name, flatConfig) : undefined
550 );
551
552 if (params) {
553 route.params = { ...route.params, ...params };
554 }
555
556 return state;
557};
558
559const parseQueryParams = (
560 path: string,
561 parseConfig?: Record<string, (value: string) => any>
562) => {
563 const query = path.split('?')[1];
564 const params = queryString.parse(query);
565
566 if (parseConfig) {
567 Object.keys(params).forEach((name) => {
568 if (
569 Object.hasOwnProperty.call(parseConfig, name) &&
570 typeof params[name] === 'string'
571 ) {
572 params[name] = parseConfig[name](params[name] as string);
573 }
574 });
575 }
576
577 return Object.keys(params).length ? params : undefined;
578};
579
\No newline at end of file