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