UNPKG

19.5 kBTypeScriptView Raw
1import {
2 findFocusedRoute,
3 getActionFromState as getActionFromStateDefault,
4 getPathFromState as getPathFromStateDefault,
5 getStateFromPath as getStateFromPathDefault,
6 NavigationContainerRef,
7 NavigationState,
8 ParamListBase,
9} from '@react-navigation/core';
10import { nanoid } from 'nanoid/non-secure';
11import * as React from 'react';
12
13import ServerContext from './ServerContext';
14import type { LinkingOptions } from './types';
15
16type ResultState = ReturnType<typeof getStateFromPathDefault>;
17
18type HistoryRecord = {
19 // Unique identifier for this record to match it with window.history.state
20 id: string;
21 // Navigation state object for the history entry
22 state: NavigationState;
23 // Path of the history entry
24 path: string;
25};
26
27const createMemoryHistory = () => {
28 let index = 0;
29 let items: HistoryRecord[] = [];
30
31 // Pending callbacks for `history.go(n)`
32 // We might modify the callback stored if it was interrupted, so we have a ref to identify it
33 const pending: { ref: unknown; cb: (interrupted?: boolean) => void }[] = [];
34
35 const interrupt = () => {
36 // If another history operation was performed we need to interrupt existing ones
37 // This makes sure that calls such as `history.replace` after `history.go` don't happen
38 // Since otherwise it won't be correct if something else has changed
39 pending.forEach((it) => {
40 const cb = it.cb;
41 it.cb = () => cb(true);
42 });
43 };
44
45 const history = {
46 get index(): number {
47 // We store an id in the state instead of an index
48 // Index could get out of sync with in-memory values if page reloads
49 const id = window.history.state?.id;
50
51 if (id) {
52 const index = items.findIndex((item) => item.id === id);
53
54 return index > -1 ? index : 0;
55 }
56
57 return 0;
58 },
59
60 get(index: number) {
61 return items[index];
62 },
63
64 backIndex({ path }: { path: string }) {
65 // We need to find the index from the element before current to get closest path to go back to
66 for (let i = index - 1; i >= 0; i--) {
67 const item = items[i];
68
69 if (item.path === path) {
70 return i;
71 }
72 }
73
74 return -1;
75 },
76
77 push({ path, state }: { path: string; state: NavigationState }) {
78 interrupt();
79
80 const id = nanoid();
81
82 // When a new entry is pushed, all the existing entries after index will be inaccessible
83 // So we remove any existing entries after the current index to clean them up
84 items = items.slice(0, index + 1);
85
86 items.push({ path, state, id });
87 index = items.length - 1;
88
89 // We pass empty string for title because it's ignored in all browsers except safari
90 // We don't store state object in history.state because:
91 // - browsers have limits on how big it can be, and we don't control the size
92 // - while not recommended, there could be non-serializable data in state
93 window.history.pushState({ id }, '', path);
94 },
95
96 replace({ path, state }: { path: string; state: NavigationState }) {
97 interrupt();
98
99 const id = window.history.state?.id ?? nanoid();
100
101 if (items.length) {
102 items[index] = { path, state, id };
103 } else {
104 // This is the first time any state modifications are done
105 // So we need to push the entry as there's nothing to replace
106 items.push({ path, state, id });
107 }
108
109 window.history.replaceState({ id }, '', path);
110 },
111
112 // `history.go(n)` is asynchronous, there are couple of things to keep in mind:
113 // - it won't do anything if we can't go `n` steps, the `popstate` event won't fire.
114 // - each `history.go(n)` call will trigger a separate `popstate` event with correct location.
115 // - the `popstate` event fires before the next frame after calling `history.go(n)`.
116 // This method differs from `history.go(n)` in the sense that it'll go back as many steps it can.
117 go(n: number) {
118 interrupt();
119
120 if (n > 0) {
121 // We shouldn't go forward more than available index
122 n = Math.min(n, items.length - 1);
123 } else if (n < 0) {
124 // We shouldn't go back more than the 0 index
125 // Otherwise we'll exit the page
126 n = index + n < 0 ? -index : n;
127 }
128
129 if (n === 0) {
130 return;
131 }
132
133 index += n;
134
135 // When we call `history.go`, `popstate` will fire when there's history to go back to
136 // So we need to somehow handle following cases:
137 // - There's history to go back, `history.go` is called, and `popstate` fires
138 // - `history.go` is called multiple times, we need to resolve on respective `popstate`
139 // - No history to go back, but `history.go` was called, browser has no API to detect it
140 return new Promise<void>((resolve, reject) => {
141 const done = (interrupted?: boolean) => {
142 clearTimeout(timer);
143
144 if (interrupted) {
145 reject(new Error('History was changed during navigation.'));
146 return;
147 }
148
149 // There seems to be a bug in Chrome regarding updating the title
150 // If we set a title just before calling `history.go`, the title gets lost
151 // However the value of `document.title` is still what we set it to
152 // It's just not displayed in the tab bar
153 // To update the tab bar, we need to reset the title to something else first (e.g. '')
154 // And set the title to what it was before so it gets applied
155 // It won't work without setting it to empty string coz otherwise title isn't changing
156 // Which means that the browser won't do anything after setting the title
157 const { title } = window.document;
158
159 window.document.title = '';
160 window.document.title = title;
161
162 resolve();
163 };
164
165 pending.push({ ref: done, cb: done });
166
167 // If navigation didn't happen within 100ms, assume that it won't happen
168 // This may not be accurate, but hopefully it won't take so much time
169 // In Chrome, navigation seems to happen instantly in next microtask
170 // But on Firefox, it seems to take much longer, around 50ms from our testing
171 // We're using a hacky timeout since there doesn't seem to be way to know for sure
172 const timer = setTimeout(() => {
173 const index = pending.findIndex((it) => it.ref === done);
174
175 if (index > -1) {
176 pending[index].cb();
177 pending.splice(index, 1);
178 }
179 }, 100);
180
181 const onPopState = () => {
182 const last = pending.pop();
183
184 window.removeEventListener('popstate', onPopState);
185 last?.cb();
186 };
187
188 window.addEventListener('popstate', onPopState);
189 window.history.go(n);
190 });
191 },
192
193 // The `popstate` event is triggered when history changes, except `pushState` and `replaceState`
194 // If we call `history.go(n)` ourselves, we don't want it to trigger the listener
195 // Here we normalize it so that only external changes (e.g. user pressing back/forward) trigger the listener
196 listen(listener: () => void) {
197 const onPopState = () => {
198 if (pending.length) {
199 // This was triggered by `history.go(n)`, we shouldn't call the listener
200 return;
201 }
202
203 listener();
204 };
205
206 window.addEventListener('popstate', onPopState);
207
208 return () => window.removeEventListener('popstate', onPopState);
209 },
210 };
211
212 return history;
213};
214
215/**
216 * Find the matching navigation state that changed between 2 navigation states
217 * e.g.: a -> b -> c -> d and a -> b -> c -> e -> f, if history in b changed, b is the matching state
218 */
219const findMatchingState = <T extends NavigationState>(
220 a: T | undefined,
221 b: T | undefined
222): [T | undefined, T | undefined] => {
223 if (a === undefined || b === undefined || a.key !== b.key) {
224 return [undefined, undefined];
225 }
226
227 // Tab and drawer will have `history` property, but stack will have history in `routes`
228 const aHistoryLength = a.history ? a.history.length : a.routes.length;
229 const bHistoryLength = b.history ? b.history.length : b.routes.length;
230
231 const aRoute = a.routes[a.index];
232 const bRoute = b.routes[b.index];
233
234 const aChildState = aRoute.state as T | undefined;
235 const bChildState = bRoute.state as T | undefined;
236
237 // Stop here if this is the state object that changed:
238 // - history length is different
239 // - focused routes are different
240 // - one of them doesn't have child state
241 // - child state keys are different
242 if (
243 aHistoryLength !== bHistoryLength ||
244 aRoute.key !== bRoute.key ||
245 aChildState === undefined ||
246 bChildState === undefined ||
247 aChildState.key !== bChildState.key
248 ) {
249 return [a, b];
250 }
251
252 return findMatchingState(aChildState, bChildState);
253};
254
255/**
256 * Run async function in series as it's called.
257 */
258const series = (cb: () => Promise<void>) => {
259 // Whether we're currently handling a callback
260 let handling = false;
261 let queue: (() => Promise<void>)[] = [];
262
263 const callback = async () => {
264 try {
265 if (handling) {
266 // If we're currently handling a previous event, wait before handling this one
267 // Add the callback to the beginning of the queue
268 queue.unshift(callback);
269 return;
270 }
271
272 handling = true;
273
274 await cb();
275 } finally {
276 handling = false;
277
278 if (queue.length) {
279 // If we have queued items, handle the last one
280 const last = queue.pop();
281
282 last?.();
283 }
284 }
285 };
286
287 return callback;
288};
289
290let isUsingLinking = false;
291
292type Options = LinkingOptions<ParamListBase> & {
293 independent?: boolean;
294};
295
296export default function useLinking(
297 ref: React.RefObject<NavigationContainerRef<ParamListBase>>,
298 {
299 independent,
300 enabled = true,
301 config,
302 getStateFromPath = getStateFromPathDefault,
303 getPathFromState = getPathFromStateDefault,
304 getActionFromState = getActionFromStateDefault,
305 }: Options
306) {
307 React.useEffect(() => {
308 if (independent) {
309 return undefined;
310 }
311
312 if (enabled !== false && isUsingLinking) {
313 throw new Error(
314 [
315 'Looks like you have configured linking in multiple places. This is likely an error since URL integration should only be handled in one place to avoid conflicts. Make sure that:',
316 "- You are not using both 'linking' prop and 'useLinking'",
317 "- You don't have 'useLinking' in multiple components",
318 ]
319 .join('\n')
320 .trim()
321 );
322 } else {
323 isUsingLinking = enabled !== false;
324 }
325
326 return () => {
327 isUsingLinking = false;
328 };
329 });
330
331 const [history] = React.useState(createMemoryHistory);
332
333 // We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners
334 // This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo`
335 // Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
336 const enabledRef = React.useRef(enabled);
337 const configRef = React.useRef(config);
338 const getStateFromPathRef = React.useRef(getStateFromPath);
339 const getPathFromStateRef = React.useRef(getPathFromState);
340 const getActionFromStateRef = React.useRef(getActionFromState);
341
342 React.useEffect(() => {
343 enabledRef.current = enabled;
344 configRef.current = config;
345 getStateFromPathRef.current = getStateFromPath;
346 getPathFromStateRef.current = getPathFromState;
347 getActionFromStateRef.current = getActionFromState;
348 });
349
350 const server = React.useContext(ServerContext);
351
352 const getInitialState = React.useCallback(() => {
353 let value: ResultState | undefined;
354
355 if (enabledRef.current) {
356 const location =
357 server?.location ??
358 (typeof window !== 'undefined' ? window.location : undefined);
359
360 const path = location ? location.pathname + location.search : undefined;
361
362 if (path) {
363 value = getStateFromPathRef.current(path, configRef.current);
364 }
365 }
366
367 const thenable = {
368 then(onfulfilled?: (state: ResultState | undefined) => void) {
369 return Promise.resolve(onfulfilled ? onfulfilled(value) : value);
370 },
371 catch() {
372 return thenable;
373 },
374 };
375
376 return thenable as PromiseLike<ResultState | undefined>;
377 // eslint-disable-next-line react-hooks/exhaustive-deps
378 }, []);
379
380 const previousIndexRef = React.useRef<number | undefined>(undefined);
381 const previousStateRef = React.useRef<NavigationState | undefined>(undefined);
382 const pendingPopStatePathRef = React.useRef<string | undefined>(undefined);
383
384 React.useEffect(() => {
385 previousIndexRef.current = history.index;
386
387 return history.listen(() => {
388 const navigation = ref.current;
389
390 if (!navigation || !enabled) {
391 return;
392 }
393
394 const path = location.pathname + location.search;
395 const index = history.index;
396
397 const previousIndex = previousIndexRef.current ?? 0;
398
399 previousIndexRef.current = index;
400 pendingPopStatePathRef.current = path;
401
402 // When browser back/forward is clicked, we first need to check if state object for this index exists
403 // If it does we'll reset to that state object
404 // Otherwise, we'll handle it like a regular deep link
405 const record = history.get(index);
406
407 if (record?.path === path && record?.state) {
408 navigation.resetRoot(record.state);
409 return;
410 }
411
412 const state = getStateFromPathRef.current(path, configRef.current);
413
414 // We should only dispatch an action when going forward
415 // Otherwise the action will likely add items to history, which would mess things up
416 if (state) {
417 // Make sure that the routes in the state exist in the root navigator
418 // Otherwise there's an error in the linking configuration
419 const rootState = navigation.getRootState();
420
421 if (state.routes.some((r) => !rootState?.routeNames.includes(r.name))) {
422 console.warn(
423 "The navigation state parsed from the URL contains routes not present in the root navigator. This usually means that the linking configuration doesn't match the navigation structure. See https://reactnavigation.org/docs/configuring-links for more details on how to specify a linking configuration."
424 );
425 return;
426 }
427
428 if (index > previousIndex) {
429 const action = getActionFromStateRef.current(
430 state,
431 configRef.current
432 );
433
434 if (action !== undefined) {
435 try {
436 navigation.dispatch(action);
437 } catch (e) {
438 // Ignore any errors from deep linking.
439 // This could happen in case of malformed links, navigation object not being initialized etc.
440 console.warn(
441 `An error occurred when trying to handle the link '${path}': ${e.message}`
442 );
443 }
444 } else {
445 navigation.resetRoot(state);
446 }
447 } else {
448 navigation.resetRoot(state);
449 }
450 } else {
451 // if current path didn't return any state, we should revert to initial state
452 navigation.resetRoot(state);
453 }
454 });
455 }, [enabled, history, ref]);
456
457 React.useEffect(() => {
458 if (!enabled) {
459 return;
460 }
461
462 if (ref.current) {
463 // We need to record the current metadata on the first render if they aren't set
464 // This will allow the initial state to be in the history entry
465 const state = ref.current.getRootState();
466
467 if (state) {
468 const route = findFocusedRoute(state);
469 const path =
470 route?.path ?? getPathFromStateRef.current(state, configRef.current);
471
472 if (previousStateRef.current === undefined) {
473 previousStateRef.current = state;
474 }
475
476 history.replace({ path, state });
477 }
478 }
479
480 const onStateChange = async () => {
481 const navigation = ref.current;
482
483 if (!navigation || !enabled) {
484 return;
485 }
486
487 const previousState = previousStateRef.current;
488 const state = navigation.getRootState();
489
490 const pendingPath = pendingPopStatePathRef.current;
491 const route = findFocusedRoute(state);
492 const path =
493 route?.path ?? getPathFromStateRef.current(state, configRef.current);
494
495 previousStateRef.current = state;
496 pendingPopStatePathRef.current = undefined;
497
498 // To detect the kind of state change, we need to:
499 // - Find the common focused navigation state in previous and current state
500 // - If only the route keys changed, compare history/routes.length to check if we go back/forward/replace
501 // - If no common focused navigation state found, it's a replace
502 const [previousFocusedState, focusedState] = findMatchingState(
503 previousState,
504 state
505 );
506
507 if (
508 previousFocusedState &&
509 focusedState &&
510 // We should only handle push/pop if path changed from what was in last `popstate`
511 // Otherwise it's likely a change triggered by `popstate`
512 path !== pendingPath
513 ) {
514 const historyDelta =
515 (focusedState.history
516 ? focusedState.history.length
517 : focusedState.routes.length) -
518 (previousFocusedState.history
519 ? previousFocusedState.history.length
520 : previousFocusedState.routes.length);
521
522 if (historyDelta > 0) {
523 // If history length is increased, we should pushState
524 // Note that path might not actually change here, for example, drawer open should pushState
525 history.push({ path, state });
526 } else if (historyDelta < 0) {
527 // If history length is decreased, i.e. entries were removed, we want to go back
528
529 const nextIndex = history.backIndex({ path });
530 const currentIndex = history.index;
531
532 try {
533 if (nextIndex !== -1 && nextIndex < currentIndex) {
534 // An existing entry for this path exists and it's less than current index, go back to that
535 await history.go(nextIndex - currentIndex);
536 } else {
537 // We couldn't find an existing entry to go back to, so we'll go back by the delta
538 // This won't be correct if multiple routes were pushed in one go before
539 // Usually this shouldn't happen and this is a fallback for that
540 await history.go(historyDelta);
541 }
542
543 // Store the updated state as well as fix the path if incorrect
544 history.replace({ path, state });
545 } catch (e) {
546 // The navigation was interrupted
547 }
548 } else {
549 // If history length is unchanged, we want to replaceState
550 history.replace({ path, state });
551 }
552 } else {
553 // If no common navigation state was found, assume it's a replace
554 // This would happen if the user did a reset/conditionally changed navigators
555 history.replace({ path, state });
556 }
557 };
558
559 // We debounce onStateChange coz we don't want multiple state changes to be handled at one time
560 // This could happen since `history.go(n)` is asynchronous
561 // If `pushState` or `replaceState` were called before `history.go(n)` completes, it'll mess stuff up
562 return ref.current?.addListener('state', series(onStateChange));
563 });
564
565 return {
566 getInitialState,
567 };
568}