1 | import type {
|
2 | InitialState,
|
3 | NavigationState,
|
4 | PartialState,
|
5 | } from '@react-navigation/routers';
|
6 | import escape from 'escape-string-regexp';
|
7 | import * as queryString from 'query-string';
|
8 |
|
9 | import findFocusedRoute from './findFocusedRoute';
|
10 | import type { PathConfigMap } from './types';
|
11 | import validatePathConfig from './validatePathConfig';
|
12 |
|
13 | type Options<ParamList extends {}> = {
|
14 | initialRouteName?: string;
|
15 | screens: PathConfigMap<ParamList>;
|
16 | };
|
17 |
|
18 | type ParseConfig = Record<string, (value: string) => any>;
|
19 |
|
20 | type RouteConfig = {
|
21 | screen: string;
|
22 | regex?: RegExp;
|
23 | path: string;
|
24 | pattern: string;
|
25 | routeNames: string[];
|
26 | parse?: ParseConfig;
|
27 | };
|
28 |
|
29 | type InitialRouteConfig = {
|
30 | initialRouteName: string;
|
31 | parentScreens: string[];
|
32 | };
|
33 |
|
34 | type ResultState = PartialState<NavigationState> & {
|
35 | state?: ResultState;
|
36 | };
|
37 |
|
38 | type ParsedRoute = {
|
39 | name: string;
|
40 | path?: string;
|
41 | params?: Record<string, any> | undefined;
|
42 | };
|
43 |
|
44 |
|
45 |
|
46 |
|
47 |
|
48 |
|
49 |
|
50 |
|
51 |
|
52 |
|
53 |
|
54 |
|
55 |
|
56 |
|
57 |
|
58 |
|
59 |
|
60 |
|
61 |
|
62 |
|
63 |
|
64 |
|
65 | export 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, '/')
|
86 | .replace(/^\//, '')
|
87 | .replace(/\?.*$/, '');
|
88 |
|
89 |
|
90 | remaining = remaining.endsWith('/') ? remaining : `${remaining}/`;
|
91 |
|
92 | if (screens === undefined) {
|
93 |
|
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 |
|
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 |
|
124 |
|
125 |
|
126 |
|
127 |
|
128 |
|
129 | if (a.pattern === b.pattern) {
|
130 | return b.routeNames.join('>').localeCompare(a.routeNames.join('>'));
|
131 | }
|
132 |
|
133 |
|
134 |
|
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 |
|
148 | if (aParts[i] == null) {
|
149 | return 1;
|
150 | }
|
151 |
|
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 |
|
158 | if (aWildCard && bWildCard) {
|
159 | continue;
|
160 | }
|
161 |
|
162 | if (aWildCard) {
|
163 | return 1;
|
164 | }
|
165 |
|
166 | if (bWildCard) {
|
167 | return -1;
|
168 | }
|
169 | }
|
170 | return bParts.length - aParts.length;
|
171 | });
|
172 |
|
173 |
|
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 |
|
180 |
|
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 |
|
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 |
|
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 |
|
254 | const joinPaths = (...paths: string[]): string =>
|
255 | ([] as string[])
|
256 | .concat(...paths.map((p) => p.split('/')))
|
257 | .filter(Boolean)
|
258 | .join('/');
|
259 |
|
260 | const matchAgainstConfigs = (remaining: string, configs: RouteConfig[]) => {
|
261 | let routes: ParsedRoute[] | undefined;
|
262 | let remainingPath = remaining;
|
263 |
|
264 |
|
265 | for (const config of configs) {
|
266 | if (!config.regex) {
|
267 | continue;
|
268 | }
|
269 |
|
270 | const match = remainingPath.match(config.regex);
|
271 |
|
272 |
|
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 |
|
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 |
|
318 | const 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 |
|
333 | const config = routeConfig[screen];
|
334 |
|
335 | if (typeof config === 'string') {
|
336 |
|
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 |
|
344 |
|
345 |
|
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 |
|
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 |
|
398 | const createConfigItem = (
|
399 | screen: string,
|
400 | routeNames: string[],
|
401 | pattern: string,
|
402 | path: string,
|
403 | parse?: ParseConfig
|
404 | ): RouteConfig => {
|
405 |
|
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 |
|
434 | const 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
|
448 | const 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
|
474 | const 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 |
|
504 | const 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 |
|
559 | const 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 |