1 | import {
|
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';
|
10 | import { nanoid } from 'nanoid/non-secure';
|
11 | import * as React from 'react';
|
12 |
|
13 | import ServerContext from './ServerContext';
|
14 | import type { LinkingOptions } from './types';
|
15 |
|
16 | type ResultState = ReturnType<typeof getStateFromPathDefault>;
|
17 |
|
18 | type HistoryRecord = {
|
19 |
|
20 | id: string;
|
21 |
|
22 | state: NavigationState;
|
23 |
|
24 | path: string;
|
25 | };
|
26 |
|
27 | const createMemoryHistory = () => {
|
28 | let index = 0;
|
29 | let items: HistoryRecord[] = [];
|
30 |
|
31 |
|
32 |
|
33 | const pending: { ref: unknown; cb: (interrupted?: boolean) => void }[] = [];
|
34 |
|
35 | const interrupt = () => {
|
36 |
|
37 |
|
38 |
|
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 |
|
48 |
|
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 |
|
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 |
|
83 |
|
84 | items = items.slice(0, index + 1);
|
85 |
|
86 | items.push({ path, state, id });
|
87 | index = items.length - 1;
|
88 |
|
89 |
|
90 |
|
91 |
|
92 |
|
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 |
|
105 |
|
106 | items.push({ path, state, id });
|
107 | }
|
108 |
|
109 | window.history.replaceState({ id }, '', path);
|
110 | },
|
111 |
|
112 |
|
113 |
|
114 |
|
115 |
|
116 |
|
117 | go(n: number) {
|
118 | interrupt();
|
119 |
|
120 | if (n > 0) {
|
121 |
|
122 | n = Math.min(n, items.length - 1);
|
123 | } else if (n < 0) {
|
124 |
|
125 |
|
126 | n = index + n < 0 ? -index : n;
|
127 | }
|
128 |
|
129 | if (n === 0) {
|
130 | return;
|
131 | }
|
132 |
|
133 | index += n;
|
134 |
|
135 |
|
136 |
|
137 |
|
138 |
|
139 |
|
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 |
|
150 |
|
151 |
|
152 |
|
153 |
|
154 |
|
155 |
|
156 |
|
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 |
|
168 |
|
169 |
|
170 |
|
171 |
|
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 |
|
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 |
|
217 |
|
218 |
|
219 | const 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 |
|
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 |
|
238 |
|
239 |
|
240 |
|
241 |
|
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 |
|
257 |
|
258 | const series = (cb: () => Promise<void>) => {
|
259 |
|
260 | let handling = false;
|
261 | let queue: (() => Promise<void>)[] = [];
|
262 |
|
263 | const callback = async () => {
|
264 | try {
|
265 | if (handling) {
|
266 |
|
267 |
|
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 |
|
280 | const last = queue.pop();
|
281 |
|
282 | last?.();
|
283 | }
|
284 | }
|
285 | };
|
286 |
|
287 | return callback;
|
288 | };
|
289 |
|
290 | let isUsingLinking = false;
|
291 |
|
292 | type Options = LinkingOptions<ParamListBase> & {
|
293 | independent?: boolean;
|
294 | };
|
295 |
|
296 | export 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 |
|
334 |
|
335 |
|
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 |
|
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 |
|
403 |
|
404 |
|
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 |
|
415 |
|
416 | if (state) {
|
417 |
|
418 |
|
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 |
|
439 |
|
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 |
|
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 |
|
464 |
|
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 |
|
499 |
|
500 |
|
501 |
|
502 | const [previousFocusedState, focusedState] = findMatchingState(
|
503 | previousState,
|
504 | state
|
505 | );
|
506 |
|
507 | if (
|
508 | previousFocusedState &&
|
509 | focusedState &&
|
510 |
|
511 |
|
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 |
|
524 |
|
525 | history.push({ path, state });
|
526 | } else if (historyDelta < 0) {
|
527 |
|
528 |
|
529 | const nextIndex = history.backIndex({ path });
|
530 | const currentIndex = history.index;
|
531 |
|
532 | try {
|
533 | if (nextIndex !== -1 && nextIndex < currentIndex) {
|
534 |
|
535 | await history.go(nextIndex - currentIndex);
|
536 | } else {
|
537 |
|
538 |
|
539 |
|
540 | await history.go(historyDelta);
|
541 | }
|
542 |
|
543 |
|
544 | history.replace({ path, state });
|
545 | } catch (e) {
|
546 |
|
547 | }
|
548 | } else {
|
549 |
|
550 | history.replace({ path, state });
|
551 | }
|
552 | } else {
|
553 |
|
554 |
|
555 | history.replace({ path, state });
|
556 | }
|
557 | };
|
558 |
|
559 |
|
560 |
|
561 |
|
562 | return ref.current?.addListener('state', series(onStateChange));
|
563 | });
|
564 |
|
565 | return {
|
566 | getInitialState,
|
567 | };
|
568 | }
|