1import { PathConfigMap } from '@react-navigation/core'; 2import type { InitialState, NavigationState, PartialState } from '@react-navigation/routers'; 3import escape from 'escape-string-regexp'; 4import Constants from 'expo-constants'; 5import * as queryString from 'query-string'; 6import URL from 'url-parse'; 7 8import { findFocusedRoute } from './findFocusedRoute'; 9import validatePathConfig from './validatePathConfig'; 10import { RouteNode } from '../Route'; 11import { matchGroupName, stripGroupSegmentsFromPath } from '../matchers'; 12 13type Options<ParamList extends object> = { 14 initialRouteName?: string; 15 screens: PathConfigMap<ParamList>; 16}; 17 18type ParseConfig = Record<string, (value: string) => any>; 19 20type RouteConfig = { 21 isInitial?: boolean; 22 screen: string; 23 regex?: RegExp; 24 path: string; 25 pattern: string; 26 routeNames: string[]; 27 parse?: ParseConfig; 28 hasChildren: boolean; 29 userReadableName: string; 30 _route?: RouteNode; 31}; 32 33type InitialRouteConfig = { 34 initialRouteName: string; 35 parentScreens: string[]; 36}; 37 38export type ResultState = PartialState<NavigationState> & { 39 state?: ResultState; 40}; 41 42type ParsedRoute = { 43 name: string; 44 path?: string; 45 params?: Record<string, any> | undefined; 46}; 47 48export function getUrlWithReactNavigationConcessions( 49 path: string, 50 basePath: string | undefined = Constants.expoConfig?.experiments?.basePath 51) { 52 const parsed = new URL(path, 'https://acme.com'); 53 const pathname = parsed.pathname; 54 55 // Make sure there is a trailing slash 56 return { 57 // The slashes are at the end, not the beginning 58 nonstandardPathname: 59 stripBasePath(pathname, basePath).replace(/^\/+/g, '').replace(/\/+$/g, '') + '/', 60 61 // React Navigation doesn't support hashes, so here 62 inputPathnameWithoutHash: stripBasePath(path, basePath).replace(/#.*$/, ''), 63 }; 64} 65 66/** 67 * Utility to parse a path string to initial state object accepted by the container. 68 * This is useful for deep linking when we need to handle the incoming URL. 69 * 70 * @example 71 * ```js 72 * getStateFromPath( 73 * '/chat/jane/42', 74 * { 75 * screens: { 76 * Chat: { 77 * path: 'chat/:author/:id', 78 * parse: { id: Number } 79 * } 80 * } 81 * } 82 * ) 83 * ``` 84 * @param path Path string to parse and convert, e.g. /foo/bar?count=42. 85 * @param options Extra options to fine-tune how to parse the path. 86 */ 87export default function getStateFromPath<ParamList extends object>( 88 path: string, 89 options?: Options<ParamList> 90): ResultState | undefined { 91 const { initialRoutes, configs } = getMatchableRouteConfigs(options); 92 93 return getStateFromPathWithConfigs(path, configs, initialRoutes); 94} 95 96export function getMatchableRouteConfigs<ParamList extends object>(options?: Options<ParamList>) { 97 if (options) { 98 validatePathConfig(options); 99 } 100 101 const screens = options?.screens; 102 // Expo Router disallows usage without a linking config. 103 if (!screens) { 104 throw Error("You must pass a 'screens' object to 'getStateFromPath' to generate a path."); 105 } 106 107 // This will be mutated... 108 const initialRoutes: InitialRouteConfig[] = []; 109 110 if (options?.initialRouteName) { 111 initialRoutes.push({ 112 initialRouteName: options.initialRouteName, 113 parentScreens: [], 114 }); 115 } 116 117 // Create a normalized configs array which will be easier to use. 118 const converted = Object.keys(screens) 119 .map((key) => createNormalizedConfigs(key, screens, [], initialRoutes)) 120 .flat(); 121 122 const resolvedInitialPatterns = initialRoutes.map((route) => 123 joinPaths(...route.parentScreens, route.initialRouteName) 124 ); 125 126 const convertedWithInitial = converted.map((config) => ({ 127 ...config, 128 // TODO(EvanBacon): Probably a safer way to do this 129 // Mark initial routes to give them potential priority over other routes that match. 130 isInitial: resolvedInitialPatterns.includes(config.routeNames.join('/')), 131 })); 132 133 // Sort in order of resolution. This is extremely important for the algorithm to work. 134 const configs = convertedWithInitial.sort(sortConfigs); 135 136 // Assert any duplicates before we start parsing. 137 assertConfigDuplicates(configs); 138 139 return { configs, initialRoutes }; 140} 141 142function assertConfigDuplicates(configs: RouteConfig[]) { 143 // Check for duplicate patterns in the config 144 configs.reduce<Record<string, RouteConfig>>((acc, config) => { 145 // NOTE(EvanBacon): Uses the regex pattern as key to detect duplicate slugs. 146 const indexedKey = config.regex?.toString() ?? config.pattern; 147 const alpha = acc[indexedKey]; 148 // NOTE(EvanBacon): Skips checking nodes that have children. 149 if (alpha && !alpha.hasChildren && !config.hasChildren) { 150 const a = alpha.routeNames; 151 const b = config.routeNames; 152 153 // It's not a problem if the path string omitted from a inner most screen 154 // For example, it's ok if a path resolves to `A > B > C` or `A > B` 155 const intersects = 156 a.length > b.length ? b.every((it, i) => a[i] === it) : a.every((it, i) => b[i] === it); 157 158 if (!intersects) { 159 // NOTE(EvanBacon): Adds more context to the error message since we know about the 160 // file-based routing. 161 const last = config.pattern.split('/').pop(); 162 const routeType = last?.startsWith(':') 163 ? 'dynamic route' 164 : last?.startsWith('*') 165 ? 'dynamic-rest route' 166 : 'route'; 167 throw new Error( 168 `The ${routeType} pattern '${config.pattern || '/'}' resolves to both '${ 169 alpha.userReadableName 170 }' and '${ 171 config.userReadableName 172 }'. Patterns must be unique and cannot resolve to more than one route.` 173 ); 174 } 175 } 176 177 return Object.assign(acc, { 178 [indexedKey]: config, 179 }); 180 }, {}); 181} 182 183function sortConfigs(a: RouteConfig, b: RouteConfig): number { 184 // Sort config so that: 185 // - the most exhaustive ones are always at the beginning 186 // - patterns with wildcard are always at the end 187 188 // If 2 patterns are same, move the one with less route names up 189 // This is an error state, so it's only useful for consistent error messages 190 if (a.pattern === b.pattern) { 191 return b.routeNames.join('>').localeCompare(a.routeNames.join('>')); 192 } 193 194 // If one of the patterns starts with the other, it's more exhaustive 195 // So move it up 196 if ( 197 a.pattern.startsWith(b.pattern) && 198 // NOTE(EvanBacon): This is a hack to make sure that `*` is always at the end 199 b.screen !== 'index' 200 ) { 201 return -1; 202 } 203 204 if (b.pattern.startsWith(a.pattern) && a.screen !== 'index') { 205 return 1; 206 } 207 208 // NOTE(EvanBacon): Here we append `index` if the screen was `index` so the length is the same 209 // as a slug or wildcard when nested more than one level deep. 210 // This is so we can compare the length of the pattern, e.g. `foo/*` > `foo` vs `*` < ``. 211 const aParts = a.pattern 212 .split('/') 213 // Strip out group names to ensure they don't affect the priority. 214 .filter((part) => matchGroupName(part) == null); 215 if (a.screen === 'index') { 216 aParts.push('index'); 217 } 218 219 const bParts = b.pattern.split('/').filter((part) => matchGroupName(part) == null); 220 if (b.screen === 'index') { 221 bParts.push('index'); 222 } 223 224 for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { 225 // if b is longer, b get higher priority 226 if (aParts[i] == null) { 227 return 1; 228 } 229 // if a is longer, a get higher priority 230 if (bParts[i] == null) { 231 return -1; 232 } 233 const aWildCard = aParts[i].startsWith('*'); 234 const bWildCard = bParts[i].startsWith('*'); 235 // if both are wildcard we compare next component 236 if (aWildCard && bWildCard) { 237 continue; 238 } 239 // if only a is wild card, b get higher priority 240 if (aWildCard) { 241 return 1; 242 } 243 // if only b is wild card, a get higher priority 244 if (bWildCard) { 245 return -1; 246 } 247 248 const aSlug = aParts[i].startsWith(':'); 249 const bSlug = bParts[i].startsWith(':'); 250 // if both are wildcard we compare next component 251 if (aSlug && bSlug) { 252 continue; 253 } 254 // if only a is wild card, b get higher priority 255 if (aSlug) { 256 return 1; 257 } 258 // if only b is wild card, a get higher priority 259 if (bSlug) { 260 return -1; 261 } 262 } 263 264 // Sort initial routes with a higher priority than routes which will push more screens 265 // this ensures shared routes go to the shortest path. 266 if (a.isInitial && !b.isInitial) { 267 return -1; 268 } 269 if (!a.isInitial && b.isInitial) { 270 return 1; 271 } 272 273 return bParts.length - aParts.length; 274} 275 276function getStateFromEmptyPathWithConfigs( 277 path: string, 278 configs: RouteConfig[], 279 initialRoutes: InitialRouteConfig[] 280): ResultState | undefined { 281 // We need to add special handling of empty path so navigation to empty path also works 282 // When handling empty path, we should only look at the root level config 283 284 // NOTE(EvanBacon): We only care about matching leaf nodes. 285 const leafNodes = configs 286 .filter((config) => !config.hasChildren) 287 .map((value) => { 288 return { 289 ...value, 290 // Collapse all levels of group segments before testing. 291 // This enables `app/(one)/(two)/index.js` to be matched. 292 path: stripGroupSegmentsFromPath(value.path), 293 }; 294 }); 295 296 const match = 297 leafNodes.find( 298 (config) => 299 // NOTE(EvanBacon): Test leaf node index routes that either don't have a regex or match an empty string. 300 config.path === '' && (!config.regex || config.regex.test('')) 301 ) ?? 302 leafNodes.find( 303 (config) => 304 // NOTE(EvanBacon): Test leaf node dynamic routes that match an empty string. 305 config.path.startsWith(':') && config.regex!.test('') 306 ) ?? 307 // NOTE(EvanBacon): Test leaf node deep dynamic routes that match a slash. 308 // This should be done last to enable dynamic routes having a higher priority. 309 leafNodes.find((config) => config.path.startsWith('*') && config.regex!.test('/')); 310 311 if (!match) { 312 return undefined; 313 } 314 315 const routes = match.routeNames.map((name) => { 316 if (!match._route) { 317 return { name }; 318 } 319 return { 320 name, 321 _route: match._route, 322 }; 323 }); 324 325 return createNestedStateObject(path, routes, configs, initialRoutes); 326} 327 328function getStateFromPathWithConfigs( 329 path: string, 330 configs: RouteConfig[], 331 initialRoutes: InitialRouteConfig[] 332): ResultState | undefined { 333 const formattedPaths = getUrlWithReactNavigationConcessions(path); 334 335 if (formattedPaths.nonstandardPathname === '/') { 336 return getStateFromEmptyPathWithConfigs( 337 formattedPaths.inputPathnameWithoutHash, 338 configs, 339 initialRoutes 340 ); 341 } 342 343 // We match the whole path against the regex instead of segments 344 // This makes sure matches such as wildcard will catch any unmatched routes, even if nested 345 const routes = matchAgainstConfigs(formattedPaths.nonstandardPathname, configs); 346 347 if (routes == null) { 348 return undefined; 349 } 350 // This will always be empty if full path matched 351 return createNestedStateObject( 352 formattedPaths.inputPathnameWithoutHash, 353 routes, 354 configs, 355 initialRoutes 356 ); 357} 358 359const joinPaths = (...paths: string[]): string => 360 ([] as string[]) 361 .concat(...paths.map((p) => p.split('/'))) 362 .filter(Boolean) 363 .join('/'); 364 365function matchAgainstConfigs(remaining: string, configs: RouteConfig[]): ParsedRoute[] | undefined { 366 let routes: ParsedRoute[] | undefined; 367 let remainingPath = remaining; 368 369 // Go through all configs, and see if the next path segment matches our regex 370 for (const config of configs) { 371 if (!config.regex) { 372 continue; 373 } 374 375 const match = remainingPath.match(config.regex); 376 377 // If our regex matches, we need to extract params from the path 378 if (!match) { 379 continue; 380 } 381 382 // TODO: Add support for wildcard routes 383 const matchedParams = config.pattern 384 ?.split('/') 385 .filter((p) => p.match(/^[:*]/)) 386 .reduce<Record<string, any>>((acc, p, i) => { 387 if (p.match(/^\*/)) { 388 return { 389 ...acc, 390 [p]: match![(i + 1) * 2], //?.replace(/\//, ""), 391 }; 392 } 393 return Object.assign(acc, { 394 // The param segments appear every second item starting from 2 in the regex match result. 395 // This will only work if we ensure groups aren't included in the match. 396 [p]: match![(i + 1) * 2]?.replace(/\//, ''), 397 }); 398 }, {}); 399 400 const routeFromName = (name: string) => { 401 const config = configs.find((c) => c.screen === name); 402 if (!config?.path) { 403 return { name }; 404 } 405 406 const segments = config.path.split('/'); 407 408 const params: Record<string, any> = {}; 409 410 segments 411 .filter((p) => p.match(/^[:*]/)) 412 .forEach((p) => { 413 let value = matchedParams[p]; 414 if (value) { 415 if (p.match(/^\*/)) { 416 // Convert to an array before providing as a route. 417 value = value?.split('/').filter(Boolean); 418 } 419 420 const key = p.replace(/^[:*]/, '').replace(/\?$/, ''); 421 params[key] = config.parse?.[key] ? config.parse[key](value) : value; 422 } 423 }); 424 425 if (params && Object.keys(params).length) { 426 return { name, params }; 427 } 428 429 return { name }; 430 }; 431 432 routes = config.routeNames.map((name) => { 433 if (!config._route) { 434 return { ...routeFromName(name) }; 435 } 436 return { 437 ...routeFromName(name), 438 _route: config._route, 439 }; 440 }); 441 442 // TODO(EvanBacon): Maybe we should warn / assert if multiple slugs use the same param name. 443 const combinedParams = routes.reduce<Record<string, any>>( 444 (acc, r) => Object.assign(acc, r.params), 445 {} 446 ); 447 448 const hasCombinedParams = Object.keys(combinedParams).length > 0; 449 450 // Combine all params so a route `[foo]/[bar]/other.js` has access to `{ foo, bar }` 451 routes = routes.map((r) => { 452 if (hasCombinedParams) { 453 r.params = combinedParams; 454 } 455 return r; 456 }); 457 458 remainingPath = remainingPath.replace(match[1], ''); 459 460 break; 461 } 462 463 return routes; 464} 465 466function equalHeritage(a: string[], b: string[]): boolean { 467 if (a.length !== b.length) { 468 return false; 469 } 470 for (let i = 0; i < a.length; i++) { 471 if (a[i].localeCompare(b[i]) !== 0) { 472 return false; 473 } 474 } 475 return true; 476} 477 478const createNormalizedConfigs = ( 479 screen: string, 480 routeConfig: PathConfigMap<object>, 481 routeNames: string[] = [], 482 initials: InitialRouteConfig[] = [], 483 parentScreens: string[] = [], 484 parentPattern?: string 485): RouteConfig[] => { 486 const configs: RouteConfig[] = []; 487 488 routeNames.push(screen); 489 490 parentScreens.push(screen); 491 492 const config = (routeConfig as any)[screen]; 493 494 if (typeof config === 'string') { 495 // TODO: This should never happen with the addition of `_route` 496 497 // If a string is specified as the value of the key(e.g. Foo: '/path'), use it as the pattern 498 const pattern = parentPattern ? joinPaths(parentPattern, config) : config; 499 500 configs.push(createConfigItem(screen, routeNames, pattern, config, false)); 501 } else if (typeof config === 'object') { 502 let pattern: string | undefined; 503 504 const { _route } = config; 505 // if an object is specified as the value (e.g. Foo: { ... }), 506 // it can have `path` property and 507 // it could have `screens` prop which has nested configs 508 if (typeof config.path === 'string') { 509 if (config.exact && config.path === undefined) { 510 throw new Error( 511 "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: ''`." 512 ); 513 } 514 515 pattern = 516 config.exact !== true 517 ? joinPaths(parentPattern || '', config.path || '') 518 : config.path || ''; 519 520 configs.push( 521 createConfigItem( 522 screen, 523 routeNames, 524 pattern!, 525 config.path, 526 config.screens ? !!Object.keys(config.screens)?.length : false, 527 config.parse, 528 _route 529 ) 530 ); 531 } 532 533 if (config.screens) { 534 // property `initialRouteName` without `screens` has no purpose 535 if (config.initialRouteName) { 536 initials.push({ 537 initialRouteName: config.initialRouteName, 538 parentScreens, 539 }); 540 } 541 542 Object.keys(config.screens).forEach((nestedConfig) => { 543 const result = createNormalizedConfigs( 544 nestedConfig, 545 config.screens as PathConfigMap<object>, 546 routeNames, 547 initials, 548 [...parentScreens], 549 pattern ?? parentPattern 550 ); 551 552 configs.push(...result); 553 }); 554 } 555 } 556 557 routeNames.pop(); 558 559 return configs; 560}; 561 562function formatRegexPattern(it: string): string { 563 // Allow spaces in file path names. 564 it = it.replace(' ', '%20'); 565 566 if (it.startsWith(':')) { 567 // TODO: Remove unused match group 568 return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`; 569 } else if (it.startsWith('*')) { 570 return `((.*\\/)${it.endsWith('?') ? '?' : ''})`; 571 } 572 573 // Strip groups from the matcher 574 if (matchGroupName(it) != null) { 575 // Groups are optional segments 576 // this enables us to match `/bar` and `/(foo)/bar` for the same route 577 // NOTE(EvanBacon): Ignore this match in the regex to avoid capturing the group 578 return `(?:${escape(it)}\\/)?`; 579 } 580 581 return escape(it) + `\\/`; 582} 583 584const createConfigItem = ( 585 screen: string, 586 routeNames: string[], 587 pattern: string, 588 path: string, 589 hasChildren?: boolean, 590 parse?: ParseConfig, 591 _route?: any 592): RouteConfig => { 593 // Normalize pattern to remove any leading, trailing slashes, duplicate slashes etc. 594 pattern = pattern.split('/').filter(Boolean).join('/'); 595 596 const regex = pattern 597 ? new RegExp(`^(${pattern.split('/').map(formatRegexPattern).join('')})$`) 598 : undefined; 599 600 return { 601 screen, 602 regex, 603 pattern, 604 path, 605 // The routeNames array is mutated, so copy it to keep the current state 606 routeNames: [...routeNames], 607 parse, 608 userReadableName: [...routeNames.slice(0, -1), path || screen].join('/'), 609 hasChildren: !!hasChildren, 610 _route, 611 }; 612}; 613 614const findParseConfigForRoute = ( 615 routeName: string, 616 routeConfigs: RouteConfig[] 617): ParseConfig | undefined => { 618 for (const config of routeConfigs) { 619 if (routeName === config.routeNames[config.routeNames.length - 1]) { 620 return config.parse; 621 } 622 } 623 624 return undefined; 625}; 626 627// Try to find an initial route connected with the one passed 628const findInitialRoute = ( 629 routeName: string, 630 parentScreens: string[], 631 initialRoutes: InitialRouteConfig[] 632): string | undefined => { 633 for (const config of initialRoutes) { 634 if (equalHeritage(parentScreens, config.parentScreens)) { 635 // If the parents are the same but the route name doesn't match the initial route 636 // then we return the initial route. 637 return routeName !== config.initialRouteName ? config.initialRouteName : undefined; 638 } 639 } 640 return undefined; 641}; 642 643// returns state object with values depending on whether 644// it is the end of state and if there is initialRoute for this level 645const createStateObject = ( 646 initialRoute: string | undefined, 647 route: ParsedRoute, 648 isEmpty: boolean 649): InitialState => { 650 if (isEmpty) { 651 if (initialRoute) { 652 return { 653 index: 1, 654 routes: [{ name: initialRoute }, route], 655 }; 656 } 657 return { 658 routes: [route], 659 }; 660 } 661 662 if (initialRoute) { 663 return { 664 index: 1, 665 routes: [{ name: initialRoute }, { ...route, state: { routes: [] } }], 666 }; 667 } 668 return { 669 routes: [{ ...route, state: { routes: [] } }], 670 }; 671}; 672 673const createNestedStateObject = ( 674 path: string, 675 routes: ParsedRoute[], 676 routeConfigs: RouteConfig[], 677 initialRoutes: InitialRouteConfig[] 678) => { 679 let route = routes.shift() as ParsedRoute; 680 const parentScreens: string[] = []; 681 682 let initialRoute = findInitialRoute(route.name, parentScreens, initialRoutes); 683 684 parentScreens.push(route.name); 685 686 const state: InitialState = createStateObject(initialRoute, route, routes.length === 0); 687 688 if (routes.length > 0) { 689 let nestedState = state; 690 691 while ((route = routes.shift() as ParsedRoute)) { 692 initialRoute = findInitialRoute(route.name, parentScreens, initialRoutes); 693 694 const nestedStateIndex = nestedState.index || nestedState.routes.length - 1; 695 696 nestedState.routes[nestedStateIndex].state = createStateObject( 697 initialRoute, 698 route, 699 routes.length === 0 700 ); 701 702 if (routes.length > 0) { 703 nestedState = nestedState.routes[nestedStateIndex].state as InitialState; 704 } 705 706 parentScreens.push(route.name); 707 } 708 } 709 710 route = findFocusedRoute(state) as ParsedRoute; 711 712 // Remove groups from the path while preserving a trailing slash. 713 route.path = stripGroupSegmentsFromPath(path); 714 715 const params = parseQueryParams(route.path, findParseConfigForRoute(route.name, routeConfigs)); 716 717 if (params) { 718 const resolvedParams = { ...route.params, ...params }; 719 if (Object.keys(resolvedParams).length > 0) { 720 route.params = resolvedParams; 721 } else { 722 delete route.params; 723 } 724 } 725 726 return state; 727}; 728 729const parseQueryParams = (path: string, parseConfig?: Record<string, (value: string) => any>) => { 730 const query = path.split('?')[1]; 731 const params = queryString.parse(query); 732 733 if (parseConfig) { 734 Object.keys(params).forEach((name) => { 735 if (Object.hasOwnProperty.call(parseConfig, name) && typeof params[name] === 'string') { 736 params[name] = parseConfig[name](params[name] as string); 737 } 738 }); 739 } 740 741 return Object.keys(params).length ? params : undefined; 742}; 743 744const basePathCache = new Map<string, RegExp>(); 745 746function getBasePathRegex(basePath: string) { 747 if (basePathCache.has(basePath)) { 748 return basePathCache.get(basePath)!; 749 } 750 const regex = new RegExp(`^\\/?${escape(basePath)}`, 'g'); 751 basePathCache.set(basePath, regex); 752 return regex; 753} 754 755export function stripBasePath( 756 path: string, 757 basePath: string | undefined = Constants.expoConfig?.experiments?.basePath 758) { 759 if (process.env.NODE_ENV !== 'development') { 760 if (basePath) { 761 const reg = getBasePathRegex(basePath); 762 return path.replace(/^\/+/g, '/').replace(reg, ''); 763 } 764 } 765 return path; 766} 767