feat: integrate with history API on web

This commit is contained in:
Satyajit Sahoo
2020-01-18 18:53:37 +01:00
parent eeae11033a
commit 5a3f8356b0
10 changed files with 435 additions and 82 deletions

View File

@@ -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;
};

View 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,
};
}

View File

@@ -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,