mirror of
https://github.com/zhigang1992/react-navigation.git
synced 2026-04-29 04:45:19 +08:00
feat: integrate with history API on web
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
getStateFromPath as getStateFromPathDefault,
|
||||
getPathFromState as getPathFromStateDefault,
|
||||
} from '@react-navigation/core';
|
||||
|
||||
export type Theme = {
|
||||
dark: boolean;
|
||||
colors: {
|
||||
@@ -8,3 +13,33 @@ export type Theme = {
|
||||
border: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type LinkingOptions = {
|
||||
/**
|
||||
* The prefixes are stripped from the URL before parsing them.
|
||||
* Usually they are the `scheme` + `host` (e.g. `myapp://chat?user=jane`)
|
||||
*/
|
||||
prefixes: string[];
|
||||
/**
|
||||
* Config to fine-tune how to parse the path.
|
||||
*
|
||||
* Example:
|
||||
* ```js
|
||||
* {
|
||||
* Chat: {
|
||||
* path: 'chat/:author/:id',
|
||||
* parse: { id: Number }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
config?: Parameters<typeof getStateFromPathDefault>[1];
|
||||
/**
|
||||
* Custom function to parse the URL to a valid navigation state (advanced).
|
||||
*/
|
||||
getStateFromPath?: typeof getStateFromPathDefault;
|
||||
/**
|
||||
* Custom function to conver the state object to a valid URL (advanced).
|
||||
*/
|
||||
getPathFromState?: typeof getPathFromStateDefault;
|
||||
};
|
||||
|
||||
96
packages/native/src/useLinking.native.tsx
Normal file
96
packages/native/src/useLinking.native.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import * as React from 'react';
|
||||
import { Linking } from 'react-native';
|
||||
import {
|
||||
getActionFromState,
|
||||
getStateFromPath as getStateFromPathDefault,
|
||||
NavigationContainerRef,
|
||||
} from '@react-navigation/core';
|
||||
import { LinkingOptions } from './types';
|
||||
|
||||
let isUsingLinking = false;
|
||||
|
||||
export default function useLinking(
|
||||
ref: React.RefObject<NavigationContainerRef>,
|
||||
{
|
||||
prefixes,
|
||||
config,
|
||||
getStateFromPath = getStateFromPathDefault,
|
||||
}: LinkingOptions
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
if (isUsingLinking) {
|
||||
throw new Error(
|
||||
"Looks like you are using 'useLinking' in multiple components. This is likely an error since deep links should only be handled in one place to avoid conflicts."
|
||||
);
|
||||
} else {
|
||||
isUsingLinking = true;
|
||||
}
|
||||
|
||||
return () => {
|
||||
isUsingLinking = false;
|
||||
};
|
||||
});
|
||||
|
||||
// We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners
|
||||
// This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo`
|
||||
// Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
|
||||
const prefixesRef = React.useRef(prefixes);
|
||||
const configRef = React.useRef(config);
|
||||
const getStateFromPathRef = React.useRef(getStateFromPath);
|
||||
|
||||
React.useEffect(() => {
|
||||
prefixesRef.current = prefixes;
|
||||
configRef.current = config;
|
||||
getStateFromPathRef.current = getStateFromPath;
|
||||
}, [config, getStateFromPath, prefixes]);
|
||||
|
||||
const extractPathFromURL = React.useCallback((url: string) => {
|
||||
for (const prefix of prefixesRef.current) {
|
||||
if (url.startsWith(prefix)) {
|
||||
return url.replace(prefix, '');
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const getInitialState = React.useCallback(async () => {
|
||||
const url = await Linking.getInitialURL();
|
||||
const path = url ? extractPathFromURL(url) : null;
|
||||
|
||||
if (path) {
|
||||
return getStateFromPathRef.current(path, configRef.current);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}, [extractPathFromURL]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const listener = ({ url }: { url: string }) => {
|
||||
const path = extractPathFromURL(url);
|
||||
const navigation = ref.current;
|
||||
|
||||
if (navigation && path) {
|
||||
const state = getStateFromPathRef.current(path, configRef.current);
|
||||
|
||||
if (state) {
|
||||
const action = getActionFromState(state);
|
||||
|
||||
if (action.type === 'RESET_ROOT') {
|
||||
navigation.resetRoot(action.payload);
|
||||
} else {
|
||||
navigation.dispatch(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Linking.addEventListener('url', listener);
|
||||
|
||||
return () => Linking.removeEventListener('url', listener);
|
||||
}, [extractPathFromURL, ref]);
|
||||
|
||||
return {
|
||||
getInitialState,
|
||||
};
|
||||
}
|
||||
@@ -1,104 +1,169 @@
|
||||
import * as React from 'react';
|
||||
import { Linking } from 'react-native';
|
||||
import {
|
||||
getActionFromState,
|
||||
getStateFromPath as getStateFromPathDefault,
|
||||
getPathFromState as getPathFromStateDefault,
|
||||
NavigationContainerRef,
|
||||
NavigationState,
|
||||
getActionFromState,
|
||||
} from '@react-navigation/core';
|
||||
import { LinkingOptions } from './types';
|
||||
|
||||
type GetStateFromPath = typeof getStateFromPathDefault;
|
||||
const getStateLength = (state: NavigationState) => {
|
||||
let length = 0;
|
||||
|
||||
type Config = Parameters<GetStateFromPath>[1];
|
||||
if (state.history) {
|
||||
length = state.history.length;
|
||||
} else {
|
||||
length = state.index + 1;
|
||||
}
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* The prefixes are stripped from the URL before parsing them.
|
||||
* Usually they are the `scheme` + `host` (e.g. `myapp://chat?user=jane`)
|
||||
*/
|
||||
prefixes: string[];
|
||||
/**
|
||||
* Config to fine-tune how to parse the path.
|
||||
*
|
||||
* Example:
|
||||
* ```js
|
||||
* {
|
||||
* Chat: {
|
||||
* path: 'chat/:author/:id',
|
||||
* parse: { id: Number }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
config?: Config;
|
||||
/**
|
||||
* Custom function to parse the URL object to a valid navigation state (advanced).
|
||||
*/
|
||||
getStateFromPath?: GetStateFromPath;
|
||||
const focusedState = state.routes[state.index].state;
|
||||
|
||||
if (focusedState && !focusedState.stale) {
|
||||
// If the focused route has history entries, we need to count them as well
|
||||
length += getStateLength(focusedState as NavigationState) - 1;
|
||||
}
|
||||
|
||||
return length;
|
||||
};
|
||||
|
||||
let isListenerSet = false;
|
||||
let isUsingLinking = false;
|
||||
|
||||
export default function useLinking(
|
||||
ref: React.RefObject<NavigationContainerRef>,
|
||||
{ prefixes, config, getStateFromPath = getStateFromPathDefault }: Options
|
||||
{
|
||||
prefixes,
|
||||
config,
|
||||
getStateFromPath = getStateFromPathDefault,
|
||||
getPathFromState = getPathFromStateDefault,
|
||||
}: LinkingOptions
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
if (isListenerSet) {
|
||||
if (isUsingLinking) {
|
||||
throw new Error(
|
||||
"Looks like you are using 'useLinking' in multiple components. This is likely an error since deep links need to be handled only once."
|
||||
"Looks like you are using 'useLinking' in multiple components. This is likely an error since URL integration should only be handled in one place to avoid conflicts."
|
||||
);
|
||||
} else {
|
||||
isListenerSet = true;
|
||||
isUsingLinking = true;
|
||||
}
|
||||
|
||||
return () => {
|
||||
isListenerSet = false;
|
||||
isUsingLinking = false;
|
||||
};
|
||||
});
|
||||
|
||||
// We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners
|
||||
// This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo`
|
||||
// Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
|
||||
const prefixesRef = React.useRef(prefixes);
|
||||
const configRef = React.useRef(config);
|
||||
const getStateFromPathRef = React.useRef(getStateFromPath);
|
||||
const getPathFromStateRef = React.useRef(getPathFromState);
|
||||
|
||||
React.useEffect(() => {
|
||||
prefixesRef.current = prefixes;
|
||||
configRef.current = config;
|
||||
getStateFromPathRef.current = getStateFromPath;
|
||||
}, [config, getStateFromPath, prefixes]);
|
||||
getPathFromStateRef.current = getPathFromState;
|
||||
}, [config, getPathFromState, getStateFromPath, prefixes]);
|
||||
|
||||
const extractPathFromURL = React.useCallback((url: string) => {
|
||||
for (const prefix of prefixesRef.current) {
|
||||
if (url.startsWith(prefix)) {
|
||||
return url.replace(prefix, '');
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const getInitialState = React.useCallback(async () => {
|
||||
const url = await Linking.getInitialURL();
|
||||
const path = url ? extractPathFromURL(url) : null;
|
||||
const getInitialState = React.useCallback(() => {
|
||||
const path = location.pathname + location.search;
|
||||
|
||||
if (path) {
|
||||
return getStateFromPathRef.current(path, configRef.current);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}, [extractPathFromURL]);
|
||||
}, []);
|
||||
|
||||
const previousStateLengthRef = React.useRef<number | undefined>(undefined);
|
||||
const previousHistoryIndexRef = React.useRef(0);
|
||||
|
||||
const pendingIndexChangeRef = React.useRef<number | undefined>();
|
||||
const pendingStateUpdateRef = React.useRef<boolean>(false);
|
||||
const pendingStateMultiUpdateRef = React.useRef<boolean>(false);
|
||||
|
||||
// If we're navigating ahead >1, we're not restoring whole state,
|
||||
// but just navigate to the selected route not caring about previous routes
|
||||
// therefore if we need to go back, we need to pop screen and navigate to the new one
|
||||
// Possibly, we will need to reuse the same mechanism.
|
||||
// E.g. if we went ahead+4 (numberOfIndicesAhead = 3), and back-2,
|
||||
// actually we need to pop the screen we navigated
|
||||
// and navigate again, setting numberOfIndicesAhead to 1.
|
||||
const numberOfIndicesAhead = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const listener = ({ url }: { url: string }) => {
|
||||
const path = extractPathFromURL(url);
|
||||
window.addEventListener('popstate', () => {
|
||||
const navigation = ref.current;
|
||||
|
||||
if (navigation && path) {
|
||||
const state = getStateFromPathRef.current(path, configRef.current);
|
||||
if (!navigation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousHistoryIndex = previousHistoryIndexRef.current;
|
||||
const historyIndex = history.state?.index ?? 0;
|
||||
|
||||
previousHistoryIndexRef.current = historyIndex;
|
||||
|
||||
if (pendingIndexChangeRef.current === historyIndex) {
|
||||
pendingIndexChangeRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const state = navigation.getRootState();
|
||||
const path = getPathFromStateRef.current(state, configRef.current);
|
||||
|
||||
let canGoBack = true;
|
||||
let numberOfBacks = 0;
|
||||
|
||||
if (previousHistoryIndex === historyIndex) {
|
||||
if (location.pathname + location.search !== path) {
|
||||
pendingStateUpdateRef.current = true;
|
||||
history.replaceState({ index: historyIndex }, '', path);
|
||||
}
|
||||
} else if (previousHistoryIndex > historyIndex) {
|
||||
numberOfBacks =
|
||||
previousHistoryIndex - historyIndex - numberOfIndicesAhead.current;
|
||||
|
||||
if (numberOfBacks > 0) {
|
||||
pendingStateMultiUpdateRef.current = true;
|
||||
|
||||
if (numberOfBacks > 1) {
|
||||
pendingStateMultiUpdateRef.current = true;
|
||||
}
|
||||
|
||||
pendingStateUpdateRef.current = true;
|
||||
|
||||
for (let i = 0; i < numberOfBacks; i++) {
|
||||
navigation.goBack();
|
||||
}
|
||||
} else {
|
||||
canGoBack = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (previousHistoryIndex < historyIndex || !canGoBack) {
|
||||
if (canGoBack) {
|
||||
numberOfIndicesAhead.current =
|
||||
historyIndex - previousHistoryIndex - 1;
|
||||
} else {
|
||||
navigation.goBack();
|
||||
numberOfIndicesAhead.current -= previousHistoryIndex - historyIndex;
|
||||
}
|
||||
|
||||
const state = getStateFromPathRef.current(
|
||||
location.pathname + location.search,
|
||||
configRef.current
|
||||
);
|
||||
|
||||
pendingStateMultiUpdateRef.current = true;
|
||||
|
||||
if (state) {
|
||||
const action = getActionFromState(state);
|
||||
|
||||
pendingStateUpdateRef.current = true;
|
||||
|
||||
if (action.type === 'RESET_ROOT') {
|
||||
navigation.resetRoot(action.payload);
|
||||
} else {
|
||||
@@ -106,12 +171,88 @@ export default function useLinking(
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}, [ref]);
|
||||
|
||||
Linking.addEventListener('url', listener);
|
||||
React.useEffect(() => {
|
||||
if (ref.current && previousStateLengthRef.current === undefined) {
|
||||
previousStateLengthRef.current = getStateLength(
|
||||
ref.current.getRootState()
|
||||
);
|
||||
}
|
||||
|
||||
return () => Linking.removeEventListener('url', listener);
|
||||
}, [extractPathFromURL, ref]);
|
||||
if (ref.current && location.pathname + location.search === '/') {
|
||||
history.replaceState(
|
||||
{ index: history.state?.index ?? 0 },
|
||||
'',
|
||||
getPathFromStateRef.current(
|
||||
ref.current.getRootState(),
|
||||
configRef.current
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const unsubscribe = ref.current?.addListener('state', () => {
|
||||
const navigation = ref.current;
|
||||
|
||||
if (!navigation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = navigation.getRootState();
|
||||
const path = getPathFromStateRef.current(state, configRef.current);
|
||||
|
||||
const previousStateLength = previousStateLengthRef.current ?? 1;
|
||||
const stateLength = getStateLength(state);
|
||||
|
||||
if (pendingStateMultiUpdateRef.current) {
|
||||
if (location.pathname + location.search === path) {
|
||||
pendingStateMultiUpdateRef.current = false;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
previousStateLengthRef.current = stateLength;
|
||||
|
||||
if (
|
||||
pendingStateUpdateRef.current &&
|
||||
location.pathname + location.search === path
|
||||
) {
|
||||
pendingStateUpdateRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let index = history.state?.index ?? 0;
|
||||
|
||||
if (previousStateLength === stateLength) {
|
||||
// If no new enrties were added to history in our navigation state, we want to replaceState
|
||||
if (location.pathname + location.search !== path) {
|
||||
history.replaceState({ index }, '', path);
|
||||
previousHistoryIndexRef.current = index;
|
||||
}
|
||||
} else if (stateLength > previousStateLength) {
|
||||
// If new enrties were added, pushState until we have same length
|
||||
// This won't be accurate if multiple enrties were added at once, but that's the best we can do
|
||||
for (let i = 0, l = stateLength - previousStateLength; i < l; i++) {
|
||||
index++;
|
||||
history.pushState({ index }, '', path);
|
||||
}
|
||||
|
||||
previousHistoryIndexRef.current = index;
|
||||
} else if (previousStateLength > stateLength) {
|
||||
const delta = previousStateLength - stateLength;
|
||||
|
||||
// We need to set this to ignore the `popstate` event
|
||||
pendingIndexChangeRef.current = index - delta;
|
||||
|
||||
// If new enrties were removed, go back so that we have same length
|
||||
history.go(-delta);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
return {
|
||||
getInitialState,
|
||||
|
||||
Reference in New Issue
Block a user