UNPKG

8.36 kBTypeScriptView Raw
1import type {
2 NavigationState,
3 PartialState,
4 Route,
5} from '@react-navigation/routers';
6import * as queryString from 'query-string';
7
8import fromEntries from './fromEntries';
9import type { PathConfig, PathConfigMap } from './types';
10import validatePathConfig from './validatePathConfig';
11
12type Options<ParamList> = {
13 initialRouteName?: string;
14 screens: PathConfigMap<ParamList>;
15};
16
17type State = NavigationState | Omit<PartialState<NavigationState>, 'stale'>;
18
19type StringifyConfig = Record<string, (value: any) => string>;
20
21type ConfigItem = {
22 pattern?: string;
23 stringify?: StringifyConfig;
24 screens?: Record<string, ConfigItem>;
25};
26
27const getActiveRoute = (state: State): { name: string; params?: object } => {
28 const route =
29 typeof state.index === 'number'
30 ? state.routes[state.index]
31 : state.routes[state.routes.length - 1];
32
33 if (route.state) {
34 return getActiveRoute(route.state);
35 }
36
37 return route;
38};
39
40/**
41 * Utility to serialize a navigation state object to a path string.
42 *
43 * @example
44 * ```js
45 * getPathFromState(
46 * {
47 * routes: [
48 * {
49 * name: 'Chat',
50 * params: { author: 'Jane', id: 42 },
51 * },
52 * ],
53 * },
54 * {
55 * screens: {
56 * Chat: {
57 * path: 'chat/:author/:id',
58 * stringify: { author: author => author.toLowerCase() }
59 * }
60 * }
61 * }
62 * )
63 * ```
64 *
65 * @param state Navigation state to serialize.
66 * @param options Extra options to fine-tune how to serialize the path.
67 * @returns Path representing the state, e.g. /foo/bar?count=42.
68 */
69export default function getPathFromState<ParamList extends {}>(
70 state: State,
71 options?: Options<ParamList>
72): string {
73 if (state == null) {
74 throw Error(
75 "Got 'undefined' for the navigation state. You must pass a valid state object."
76 );
77 }
78
79 if (options) {
80 validatePathConfig(options);
81 }
82
83 // Create a normalized configs object which will be easier to use
84 const configs: Record<string, ConfigItem> = options?.screens
85 ? createNormalizedConfigs(options?.screens)
86 : {};
87
88 let path = '/';
89 let current: State | undefined = state;
90
91 const allParams: Record<string, any> = {};
92
93 while (current) {
94 let index = typeof current.index === 'number' ? current.index : 0;
95 let route = current.routes[index] as Route<string> & {
96 state?: State;
97 };
98
99 let pattern: string | undefined;
100
101 let focusedParams: Record<string, any> | undefined;
102 let focusedRoute = getActiveRoute(state);
103 let currentOptions = configs;
104
105 // Keep all the route names that appeared during going deeper in config in case the pattern is resolved to undefined
106 let nestedRouteNames = [];
107
108 let hasNext = true;
109
110 while (route.name in currentOptions && hasNext) {
111 pattern = currentOptions[route.name].pattern;
112
113 nestedRouteNames.push(route.name);
114
115 if (route.params) {
116 const stringify = currentOptions[route.name]?.stringify;
117
118 const currentParams = fromEntries(
119 Object.entries(route.params).map(([key, value]) => [
120 key,
121 stringify?.[key] ? stringify[key](value) : String(value),
122 ])
123 );
124
125 if (pattern) {
126 Object.assign(allParams, currentParams);
127 }
128
129 if (focusedRoute === route) {
130 // If this is the focused route, keep the params for later use
131 // We save it here since it's been stringified already
132 focusedParams = { ...currentParams };
133
134 pattern
135 ?.split('/')
136 .filter((p) => p.startsWith(':'))
137 // eslint-disable-next-line no-loop-func
138 .forEach((p) => {
139 const name = getParamName(p);
140
141 // Remove the params present in the pattern since we'll only use the rest for query string
142 if (focusedParams) {
143 // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
144 delete focusedParams[name];
145 }
146 });
147 }
148 }
149
150 // If there is no `screens` property or no nested state, we return pattern
151 if (!currentOptions[route.name].screens || route.state === undefined) {
152 hasNext = false;
153 } else {
154 index =
155 typeof route.state.index === 'number'
156 ? route.state.index
157 : route.state.routes.length - 1;
158
159 const nextRoute = route.state.routes[index];
160 const nestedConfig = currentOptions[route.name].screens;
161
162 // if there is config for next route name, we go deeper
163 if (nestedConfig && nextRoute.name in nestedConfig) {
164 route = nextRoute as Route<string> & { state?: State };
165 currentOptions = nestedConfig;
166 } else {
167 // If not, there is no sense in going deeper in config
168 hasNext = false;
169 }
170 }
171 }
172
173 if (pattern === undefined) {
174 pattern = nestedRouteNames.join('/');
175 }
176
177 if (currentOptions[route.name] !== undefined) {
178 path += pattern
179 .split('/')
180 .map((p) => {
181 const name = getParamName(p);
182
183 // We don't know what to show for wildcard patterns
184 // Showing the route name seems ok, though whatever we show here will be incorrect
185 // Since the page doesn't actually exist
186 if (p === '*') {
187 return route.name;
188 }
189
190 // If the path has a pattern for a param, put the param in the path
191 if (p.startsWith(':')) {
192 const value = allParams[name];
193
194 if (value === undefined && p.endsWith('?')) {
195 // Optional params without value assigned in route.params should be ignored
196 return '';
197 }
198
199 return encodeURIComponent(value);
200 }
201
202 return encodeURIComponent(p);
203 })
204 .join('/');
205 } else {
206 path += encodeURIComponent(route.name);
207 }
208
209 if (!focusedParams) {
210 focusedParams = focusedRoute.params;
211 }
212
213 if (route.state) {
214 path += '/';
215 } else if (focusedParams) {
216 for (let param in focusedParams) {
217 if (focusedParams[param] === 'undefined') {
218 // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
219 delete focusedParams[param];
220 }
221 }
222
223 const query = queryString.stringify(focusedParams, { sort: false });
224
225 if (query) {
226 path += `?${query}`;
227 }
228 }
229
230 current = route.state;
231 }
232
233 // Remove multiple as well as trailing slashes
234 path = path.replace(/\/+/g, '/');
235 path = path.length > 1 ? path.replace(/\/$/, '') : path;
236
237 return path;
238}
239
240const getParamName = (pattern: string) =>
241 pattern.replace(/^:/, '').replace(/\?$/, '');
242
243const joinPaths = (...paths: string[]): string =>
244 ([] as string[])
245 .concat(...paths.map((p) => p.split('/')))
246 .filter(Boolean)
247 .join('/');
248
249const createConfigItem = (
250 config: PathConfig<object> | string,
251 parentPattern?: string
252): ConfigItem => {
253 if (typeof config === 'string') {
254 // If a string is specified as the value of the key(e.g. Foo: '/path'), use it as the pattern
255 const pattern = parentPattern ? joinPaths(parentPattern, config) : config;
256
257 return { pattern };
258 }
259
260 // If an object is specified as the value (e.g. Foo: { ... }),
261 // It can have `path` property and `screens` prop which has nested configs
262 let pattern: string | undefined;
263
264 if (config.exact && config.path === undefined) {
265 throw new Error(
266 "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: ''`."
267 );
268 }
269
270 pattern =
271 config.exact !== true
272 ? joinPaths(parentPattern || '', config.path || '')
273 : config.path || '';
274
275 const screens = config.screens
276 ? createNormalizedConfigs(config.screens, pattern)
277 : undefined;
278
279 return {
280 // Normalize pattern to remove any leading, trailing slashes, duplicate slashes etc.
281 pattern: pattern?.split('/').filter(Boolean).join('/'),
282 stringify: config.stringify,
283 screens,
284 };
285};
286
287const createNormalizedConfigs = (
288 options: PathConfigMap<object>,
289 pattern?: string
290): Record<string, ConfigItem> =>
291 fromEntries(
292 Object.entries(options).map(([name, c]) => {
293 const result = createConfigItem(c, pattern);
294
295 return [name, result];
296 })
297 );