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