UNPKG

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