Files
react-navigation/packages/core/src/getPathFromState.tsx
Satyajit Sahoo 86e64fdcd8 feat: associate path with the route it opens when deep linking (#9384)
This commit adds a new optional property on the `route` object called `path`.
This property will be added if the screen was opened from a deep link.

Having this property helps with few things:

- Preserve the URL when the path was unmatched, e.g. 404 routes
- Expose the path to the user so they could handle it manually if needed, e.g. open in a webview
- Avoid changing URL if state to path doesn't match current path, e.g. if orders of params change

Fixes #9102
2021-03-05 12:20:35 +01:00

289 lines
8.0 KiB
TypeScript

import * as queryString from 'query-string';
import type {
NavigationState,
PartialState,
Route,
} from '@react-navigation/routers';
import fromEntries from './fromEntries';
import type { PathConfig, PathConfigMap } from './types';
type Options = { initialRouteName?: string; screens: PathConfigMap };
type State = NavigationState | Omit<PartialState<NavigationState>, 'stale'>;
type StringifyConfig = Record<string, (value: any) => string>;
type ConfigItem = {
pattern?: string;
stringify?: StringifyConfig;
screens?: Record<string, ConfigItem>;
};
const getActiveRoute = (state: State): { name: string; params?: object } => {
const route =
typeof state.index === 'number'
? state.routes[state.index]
: state.routes[state.routes.length - 1];
if (route.state) {
return getActiveRoute(route.state);
}
return route;
};
/**
* Utility to serialize a navigation state object to a path string.
*
* @example
* ```js
* getPathFromState(
* {
* routes: [
* {
* name: 'Chat',
* params: { author: 'Jane', id: 42 },
* },
* ],
* },
* {
* screens: {
* Chat: {
* path: 'chat/:author/:id',
* stringify: { author: author => author.toLowerCase() }
* }
* }
* }
* )
* ```
*
* @param state Navigation state to serialize.
* @param options Extra options to fine-tune how to serialize the path.
* @returns Path representing the state, e.g. /foo/bar?count=42.
*/
export default function getPathFromState(
state: State,
options?: Options
): string {
if (state == null) {
throw Error(
"Got 'undefined' for the navigation state. You must pass a valid state object."
);
}
// Create a normalized configs object which will be easier to use
const configs: Record<string, ConfigItem> = options?.screens
? createNormalizedConfigs(options?.screens)
: {};
let path = '/';
let current: State | undefined = state;
const allParams: Record<string, any> = {};
while (current) {
let index = typeof current.index === 'number' ? current.index : 0;
let route = current.routes[index] as Route<string> & {
state?: State;
};
let pattern: string | undefined;
let focusedParams: Record<string, any> | undefined;
let focusedRoute = getActiveRoute(state);
let currentOptions = configs;
// Keep all the route names that appeared during going deeper in config in case the pattern is resolved to undefined
let nestedRouteNames = [];
let hasNext = true;
while (route.name in currentOptions && hasNext) {
pattern = currentOptions[route.name].pattern;
nestedRouteNames.push(route.name);
if (route.params) {
const stringify = currentOptions[route.name]?.stringify;
const currentParams = fromEntries(
Object.entries(route.params).map(([key, value]) => [
key,
stringify?.[key] ? stringify[key](value) : String(value),
])
);
if (pattern) {
Object.assign(allParams, currentParams);
}
if (focusedRoute === route) {
// If this is the focused route, keep the params for later use
// We save it here since it's been stringified already
focusedParams = { ...currentParams };
pattern
?.split('/')
.filter((p) => p.startsWith(':'))
// eslint-disable-next-line no-loop-func
.forEach((p) => {
const name = getParamName(p);
// Remove the params present in the pattern since we'll only use the rest for query string
if (focusedParams) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete focusedParams[name];
}
});
}
}
// If there is no `screens` property or no nested state, we return pattern
if (!currentOptions[route.name].screens || route.state === undefined) {
hasNext = false;
} else {
index =
typeof route.state.index === 'number'
? route.state.index
: route.state.routes.length - 1;
const nextRoute = route.state.routes[index];
const nestedConfig = currentOptions[route.name].screens;
// if there is config for next route name, we go deeper
if (nestedConfig && nextRoute.name in nestedConfig) {
route = nextRoute as Route<string> & { state?: State };
currentOptions = nestedConfig;
} else {
// If not, there is no sense in going deeper in config
hasNext = false;
}
}
}
if (pattern === undefined) {
pattern = nestedRouteNames.join('/');
}
if (currentOptions[route.name] !== undefined) {
path += pattern
.split('/')
.map((p) => {
const name = getParamName(p);
// We don't know what to show for wildcard patterns
// Showing the route name seems ok, though whatever we show here will be incorrect
// Since the page doesn't actually exist
if (p === '*') {
return route.name;
}
// If the path has a pattern for a param, put the param in the path
if (p.startsWith(':')) {
const value = allParams[name];
if (value === undefined && p.endsWith('?')) {
// Optional params without value assigned in route.params should be ignored
return '';
}
return encodeURIComponent(value);
}
return encodeURIComponent(p);
})
.join('/');
} else {
path += encodeURIComponent(route.name);
}
if (!focusedParams) {
focusedParams = focusedRoute.params;
}
if (route.state) {
path += '/';
} else if (focusedParams) {
for (let param in focusedParams) {
if (focusedParams[param] === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete focusedParams[param];
}
}
const query = queryString.stringify(focusedParams, { sort: false });
if (query) {
path += `?${query}`;
}
}
current = route.state;
}
// Remove multiple as well as trailing slashes
path = path.replace(/\/+/g, '/');
path = path.length > 1 ? path.replace(/\/$/, '') : path;
return path;
}
const getParamName = (pattern: string) =>
pattern.replace(/^:/, '').replace(/\?$/, '');
const joinPaths = (...paths: string[]): string =>
([] as string[])
.concat(...paths.map((p) => p.split('/')))
.filter(Boolean)
.join('/');
const createConfigItem = (
config: PathConfig | string,
parentPattern?: string
): ConfigItem => {
if (typeof config === 'string') {
// If a string is specified as the value of the key(e.g. Foo: '/path'), use it as the pattern
const pattern = parentPattern ? joinPaths(parentPattern, config) : config;
return { pattern };
}
// If an object is specified as the value (e.g. Foo: { ... }),
// It can have `path` property and `screens` prop which has nested configs
let pattern: string | undefined;
if (config.exact && config.path === undefined) {
throw new Error(
"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: ''`."
);
}
pattern =
config.exact !== true
? joinPaths(parentPattern || '', config.path || '')
: config.path || '';
const screens = config.screens
? createNormalizedConfigs(config.screens, pattern)
: undefined;
return {
// Normalize pattern to remove any leading, trailing slashes, duplicate slashes etc.
pattern: pattern?.split('/').filter(Boolean).join('/'),
stringify: config.stringify,
screens,
};
};
const createNormalizedConfigs = (
options: PathConfigMap,
pattern?: string
): Record<string, ConfigItem> =>
fromEntries(
Object.entries(options).map(([name, c]) => {
const result = createConfigItem(c, pattern);
return [name, result];
})
);