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