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 isEqual from 'fast-deep-equal';
|
11 | import * as React from 'react';
|
12 |
|
13 | import createMemoryHistory from './createMemoryHistory';
|
14 | import ServerContext from './ServerContext';
|
15 | import type { LinkingOptions } from './types';
|
16 |
|
17 | type ResultState = ReturnType<typeof getStateFromPathDefault>;
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 | const findMatchingState = <T extends NavigationState>(
|
24 | a: T | undefined,
|
25 | b: T | undefined
|
26 | ): [T | undefined, T | undefined] => {
|
27 | if (a === undefined || b === undefined || a.key !== b.key) {
|
28 | return [undefined, undefined];
|
29 | }
|
30 |
|
31 |
|
32 | const aHistoryLength = a.history ? a.history.length : a.routes.length;
|
33 | const bHistoryLength = b.history ? b.history.length : b.routes.length;
|
34 |
|
35 | const aRoute = a.routes[a.index];
|
36 | const bRoute = b.routes[b.index];
|
37 |
|
38 | const aChildState = aRoute.state as T | undefined;
|
39 | const bChildState = bRoute.state as T | undefined;
|
40 |
|
41 |
|
42 |
|
43 |
|
44 |
|
45 |
|
46 | if (
|
47 | aHistoryLength !== bHistoryLength ||
|
48 | aRoute.key !== bRoute.key ||
|
49 | aChildState === undefined ||
|
50 | bChildState === undefined ||
|
51 | aChildState.key !== bChildState.key
|
52 | ) {
|
53 | return [a, b];
|
54 | }
|
55 |
|
56 | return findMatchingState(aChildState, bChildState);
|
57 | };
|
58 |
|
59 |
|
60 |
|
61 |
|
62 | const series = (cb: () => Promise<void>) => {
|
63 |
|
64 | let handling = false;
|
65 | let queue: (() => Promise<void>)[] = [];
|
66 |
|
67 | const callback = async () => {
|
68 | try {
|
69 | if (handling) {
|
70 |
|
71 |
|
72 | queue.unshift(callback);
|
73 | return;
|
74 | }
|
75 |
|
76 | handling = true;
|
77 |
|
78 | await cb();
|
79 | } finally {
|
80 | handling = false;
|
81 |
|
82 | if (queue.length) {
|
83 |
|
84 | const last = queue.pop();
|
85 |
|
86 | last?.();
|
87 | }
|
88 | }
|
89 | };
|
90 |
|
91 | return callback;
|
92 | };
|
93 |
|
94 | let linkingHandlers: Symbol[] = [];
|
95 |
|
96 | type Options = LinkingOptions<ParamListBase> & {
|
97 | independent?: boolean;
|
98 | };
|
99 |
|
100 | export default function useLinking(
|
101 | ref: React.RefObject<NavigationContainerRef<ParamListBase>>,
|
102 | {
|
103 | independent,
|
104 | enabled = true,
|
105 | config,
|
106 | getStateFromPath = getStateFromPathDefault,
|
107 | getPathFromState = getPathFromStateDefault,
|
108 | getActionFromState = getActionFromStateDefault,
|
109 | }: Options
|
110 | ) {
|
111 | React.useEffect(() => {
|
112 | if (process.env.NODE_ENV === 'production') {
|
113 | return undefined;
|
114 | }
|
115 |
|
116 | if (independent) {
|
117 | return undefined;
|
118 | }
|
119 |
|
120 | if (enabled !== false && linkingHandlers.length) {
|
121 | console.error(
|
122 | [
|
123 | 'Looks like you have configured linking in multiple places. This is likely an error since deep links should only be handled in one place to avoid conflicts. Make sure that:',
|
124 | "- You don't have multiple NavigationContainers in the app each with 'linking' enabled",
|
125 | '- Only a single instance of the root component is rendered',
|
126 | ]
|
127 | .join('\n')
|
128 | .trim()
|
129 | );
|
130 | }
|
131 |
|
132 | const handler = Symbol();
|
133 |
|
134 | if (enabled !== false) {
|
135 | linkingHandlers.push(handler);
|
136 | }
|
137 |
|
138 | return () => {
|
139 | const index = linkingHandlers.indexOf(handler);
|
140 |
|
141 | if (index > -1) {
|
142 | linkingHandlers.splice(index, 1);
|
143 | }
|
144 | };
|
145 | }, [enabled, independent]);
|
146 |
|
147 | const [history] = React.useState(createMemoryHistory);
|
148 |
|
149 |
|
150 |
|
151 |
|
152 | const enabledRef = React.useRef(enabled);
|
153 | const configRef = React.useRef(config);
|
154 | const getStateFromPathRef = React.useRef(getStateFromPath);
|
155 | const getPathFromStateRef = React.useRef(getPathFromState);
|
156 | const getActionFromStateRef = React.useRef(getActionFromState);
|
157 |
|
158 | React.useEffect(() => {
|
159 | enabledRef.current = enabled;
|
160 | configRef.current = config;
|
161 | getStateFromPathRef.current = getStateFromPath;
|
162 | getPathFromStateRef.current = getPathFromState;
|
163 | getActionFromStateRef.current = getActionFromState;
|
164 | });
|
165 |
|
166 | const server = React.useContext(ServerContext);
|
167 |
|
168 | const getInitialState = React.useCallback(() => {
|
169 | let value: ResultState | undefined;
|
170 |
|
171 | if (enabledRef.current) {
|
172 | const location =
|
173 | server?.location ??
|
174 | (typeof window !== 'undefined' ? window.location : undefined);
|
175 |
|
176 | const path = location ? location.pathname + location.search : undefined;
|
177 |
|
178 | if (path) {
|
179 | value = getStateFromPathRef.current(path, configRef.current);
|
180 | }
|
181 | }
|
182 |
|
183 | const thenable = {
|
184 | then(onfulfilled?: (state: ResultState | undefined) => void) {
|
185 | return Promise.resolve(onfulfilled ? onfulfilled(value) : value);
|
186 | },
|
187 | catch() {
|
188 | return thenable;
|
189 | },
|
190 | };
|
191 |
|
192 | return thenable as PromiseLike<ResultState | undefined>;
|
193 |
|
194 | }, []);
|
195 |
|
196 | const previousIndexRef = React.useRef<number | undefined>(undefined);
|
197 | const previousStateRef = React.useRef<NavigationState | undefined>(undefined);
|
198 | const pendingPopStatePathRef = React.useRef<string | undefined>(undefined);
|
199 |
|
200 | React.useEffect(() => {
|
201 | previousIndexRef.current = history.index;
|
202 |
|
203 | return history.listen(() => {
|
204 | const navigation = ref.current;
|
205 |
|
206 | if (!navigation || !enabled) {
|
207 | return;
|
208 | }
|
209 |
|
210 | const path = location.pathname + location.search;
|
211 | const index = history.index;
|
212 |
|
213 | const previousIndex = previousIndexRef.current ?? 0;
|
214 |
|
215 | previousIndexRef.current = index;
|
216 | pendingPopStatePathRef.current = path;
|
217 |
|
218 |
|
219 |
|
220 |
|
221 | const record = history.get(index);
|
222 |
|
223 | if (record?.path === path && record?.state) {
|
224 | navigation.resetRoot(record.state);
|
225 | return;
|
226 | }
|
227 |
|
228 | const state = getStateFromPathRef.current(path, configRef.current);
|
229 |
|
230 |
|
231 |
|
232 | if (state) {
|
233 |
|
234 |
|
235 | const rootState = navigation.getRootState();
|
236 |
|
237 | if (state.routes.some((r) => !rootState?.routeNames.includes(r.name))) {
|
238 | console.warn(
|
239 | "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."
|
240 | );
|
241 | return;
|
242 | }
|
243 |
|
244 | if (index > previousIndex) {
|
245 | const action = getActionFromStateRef.current(
|
246 | state,
|
247 | configRef.current
|
248 | );
|
249 |
|
250 | if (action !== undefined) {
|
251 | try {
|
252 | navigation.dispatch(action);
|
253 | } catch (e) {
|
254 |
|
255 |
|
256 | console.warn(
|
257 | `An error occurred when trying to handle the link '${path}': ${
|
258 | typeof e === 'object' && e != null && 'message' in e
|
259 | ? // @ts-expect-error: we're already checking for this
|
260 | e.message
|
261 | : e
|
262 | }`
|
263 | );
|
264 | }
|
265 | } else {
|
266 | navigation.resetRoot(state);
|
267 | }
|
268 | } else {
|
269 | navigation.resetRoot(state);
|
270 | }
|
271 | } else {
|
272 |
|
273 | navigation.resetRoot(state);
|
274 | }
|
275 | });
|
276 | }, [enabled, history, ref]);
|
277 |
|
278 | React.useEffect(() => {
|
279 | if (!enabled) {
|
280 | return;
|
281 | }
|
282 |
|
283 | const getPathForRoute = (
|
284 | route: ReturnType<typeof findFocusedRoute>,
|
285 | state: NavigationState
|
286 | ): string => {
|
287 |
|
288 |
|
289 | if (route?.path) {
|
290 | const stateForPath = getStateFromPathRef.current(
|
291 | route.path,
|
292 | configRef.current
|
293 | );
|
294 |
|
295 | if (stateForPath) {
|
296 | const focusedRoute = findFocusedRoute(stateForPath);
|
297 |
|
298 | if (
|
299 | focusedRoute &&
|
300 | focusedRoute.name === route.name &&
|
301 | isEqual(focusedRoute.params, route.params)
|
302 | ) {
|
303 | return route.path;
|
304 | }
|
305 | }
|
306 | }
|
307 |
|
308 | return getPathFromStateRef.current(state, configRef.current);
|
309 | };
|
310 |
|
311 | if (ref.current) {
|
312 |
|
313 |
|
314 | const state = ref.current.getRootState();
|
315 |
|
316 | if (state) {
|
317 | const route = findFocusedRoute(state);
|
318 | const path = getPathForRoute(route, state);
|
319 |
|
320 | if (previousStateRef.current === undefined) {
|
321 | previousStateRef.current = state;
|
322 | }
|
323 |
|
324 | history.replace({ path, state });
|
325 | }
|
326 | }
|
327 |
|
328 | const onStateChange = async () => {
|
329 | const navigation = ref.current;
|
330 |
|
331 | if (!navigation || !enabled) {
|
332 | return;
|
333 | }
|
334 |
|
335 | const previousState = previousStateRef.current;
|
336 | const state = navigation.getRootState();
|
337 |
|
338 |
|
339 | if (!state) {
|
340 | return;
|
341 | }
|
342 |
|
343 | const pendingPath = pendingPopStatePathRef.current;
|
344 | const route = findFocusedRoute(state);
|
345 | const path = getPathForRoute(route, state);
|
346 |
|
347 | previousStateRef.current = state;
|
348 | pendingPopStatePathRef.current = undefined;
|
349 |
|
350 |
|
351 |
|
352 |
|
353 |
|
354 | const [previousFocusedState, focusedState] = findMatchingState(
|
355 | previousState,
|
356 | state
|
357 | );
|
358 |
|
359 | if (
|
360 | previousFocusedState &&
|
361 | focusedState &&
|
362 |
|
363 |
|
364 | path !== pendingPath
|
365 | ) {
|
366 | const historyDelta =
|
367 | (focusedState.history
|
368 | ? focusedState.history.length
|
369 | : focusedState.routes.length) -
|
370 | (previousFocusedState.history
|
371 | ? previousFocusedState.history.length
|
372 | : previousFocusedState.routes.length);
|
373 |
|
374 | if (historyDelta > 0) {
|
375 |
|
376 |
|
377 | history.push({ path, state });
|
378 | } else if (historyDelta < 0) {
|
379 |
|
380 |
|
381 | const nextIndex = history.backIndex({ path });
|
382 | const currentIndex = history.index;
|
383 |
|
384 | try {
|
385 | if (nextIndex !== -1 && nextIndex < currentIndex) {
|
386 |
|
387 | await history.go(nextIndex - currentIndex);
|
388 | } else {
|
389 |
|
390 |
|
391 |
|
392 | await history.go(historyDelta);
|
393 | }
|
394 |
|
395 |
|
396 | history.replace({ path, state });
|
397 | } catch (e) {
|
398 |
|
399 | }
|
400 | } else {
|
401 |
|
402 | history.replace({ path, state });
|
403 | }
|
404 | } else {
|
405 |
|
406 |
|
407 | history.replace({ path, state });
|
408 | }
|
409 | };
|
410 |
|
411 |
|
412 |
|
413 |
|
414 | return ref.current?.addListener('state', series(onStateChange));
|
415 | });
|
416 |
|
417 | return {
|
418 | getInitialState,
|
419 | };
|
420 | }
|