Skip to content

Commit

Permalink
feat: add API for unhandled linking (#11672)
Browse files Browse the repository at this point in the history
To make the API easier, we decided to make the hook attached directly to
the navigator, instead of returning the function to handle the link.

Now, an unhandled link will be handled on the route change via the
provided callback. In order to cancel unhandled links, we provide an
additional function `clearUnhandledLink`

---------

Co-authored-by: Satyajit Sahoo <satyajit.happy@gmail.com>
  • Loading branch information
osdnk and satya164 committed Nov 12, 2023
1 parent 5a88c74 commit 5758b26
Show file tree
Hide file tree
Showing 16 changed files with 318 additions and 353 deletions.
8 changes: 5 additions & 3 deletions example/src/Screens/LinkingScreen.tsx
Expand Up @@ -55,7 +55,6 @@ const HomeScreen = ({
};

const SignInScreen = () => {
const { handleOnNextRouteNamesChange: scheduleNext } = useUnhandledLinking();
const { signIn } = useContext(SigningContext)!;

return (
Expand All @@ -65,7 +64,6 @@ const SignInScreen = () => {
<Text style={styles.code}>{info}</Text>
<Button
onPress={() => {
scheduleNext();
signIn();
}}
>
Expand All @@ -79,14 +77,18 @@ const Stack = createStackNavigator<StackParamList>();

export function LinkingScreen() {
const [isSignedIn, setSignedIn] = React.useState(false);
const { getStateForRouteNamesChange } = useUnhandledLinking();
return (
<SigningContext.Provider
value={{
signOut: () => setSignedIn(false),
signIn: () => setSignedIn(true),
}}
>
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Navigator
screenOptions={{ headerShown: false }}
getStateForRouteNamesChange={getStateForRouteNamesChange}
>
{isSignedIn ? (
<Stack.Group>
<Stack.Screen name="Home" component={HomeScreen} />
Expand Down
36 changes: 8 additions & 28 deletions packages/core/src/BaseNavigationContainer.tsx
Expand Up @@ -10,7 +10,6 @@ import {
import * as React from 'react';
import useLatestCallback from 'use-latest-callback';

import { usePrevious } from '../utils/usePrevious';
import { checkDuplicateRouteNames } from './checkDuplicateRouteNames';
import { checkSerializable } from './checkSerializable';
import { NOT_INITIALIZED_ERROR } from './createNavigationContainerRef';
Expand All @@ -21,7 +20,6 @@ import { NavigationBuilderContext } from './NavigationBuilderContext';
import { NavigationContainerRefContext } from './NavigationContainerRefContext';
import { NavigationIndependentTreeContext } from './NavigationIndependentTreeContext';
import { NavigationStateContext } from './NavigationStateContext';
import { SetNextStateContext } from './SetNextStateContext';
import type {
NavigationContainerEventMap,
NavigationContainerProps,
Expand Down Expand Up @@ -425,38 +423,20 @@ export const BaseNavigationContainer = React.forwardRef(
}
);

const [stateForNextRouteNamesChange, setStateForNextRouteNamesChange] =
React.useState<Record<string, PartialState<NavigationState>> | null>(
null
);

const setNextStateContext = React.useMemo(
() => ({ stateForNextRouteNamesChange, setStateForNextRouteNamesChange }),
[stateForNextRouteNamesChange, setStateForNextRouteNamesChange]
);

const previousState = usePrevious(state);

if (state !== previousState && stateForNextRouteNamesChange !== null) {
setStateForNextRouteNamesChange(null);
}

return (
<NavigationIndependentTreeContext.Provider value={false}>
<NavigationContainerRefContext.Provider value={navigation}>
<NavigationBuilderContext.Provider value={builderContext}>
<NavigationStateContext.Provider value={context}>
<SetNextStateContext.Provider value={setNextStateContext}>
<UnhandledActionContext.Provider
value={onUnhandledAction ?? defaultOnUnhandledAction}
<UnhandledActionContext.Provider
value={onUnhandledAction ?? defaultOnUnhandledAction}
>
<DeprecatedNavigationInChildContext.Provider
value={navigationInChildEnabled}
>
<DeprecatedNavigationInChildContext.Provider
value={navigationInChildEnabled}
>
<EnsureSingleNavigator>{children}</EnsureSingleNavigator>
</DeprecatedNavigationInChildContext.Provider>
</UnhandledActionContext.Provider>
</SetNextStateContext.Provider>
<EnsureSingleNavigator>{children}</EnsureSingleNavigator>
</DeprecatedNavigationInChildContext.Provider>
</UnhandledActionContext.Provider>
</NavigationStateContext.Provider>
</NavigationBuilderContext.Provider>
</NavigationContainerRefContext.Provider>
Expand Down
23 changes: 0 additions & 23 deletions packages/core/src/SetNextStateContext.tsx

This file was deleted.

12 changes: 12 additions & 0 deletions packages/core/src/types.tsx
Expand Up @@ -76,6 +76,12 @@ export type DefaultNavigatorOptions<
route: RouteProp<ParamList>;
navigation: any;
}) => ScreenOptions);
/**
A function returning a state, which may be set after modifying the routes name.
*/
getStateForRouteNamesChange?: (
state: NavigationState
) => PartialState<NavigationState> | undefined;
};

export type EventMapBase = Record<
Expand Down Expand Up @@ -810,6 +816,12 @@ export type PathConfig<ParamList extends {}> = {
* Name of the initial route to use for the navigator when the path matches.
*/
initialRouteName?: keyof ParamList;
/**
* A function returning a state, which may be set after modifying the routes name.
*/
getStateForRouteNamesChange?: (
state: NavigationState
) => PartialState<NavigationState> | undefined;
};

export type PathConfigMap<ParamList extends {}> = {
Expand Down
17 changes: 2 additions & 15 deletions packages/core/src/useNavigationBuilder.tsx
Expand Up @@ -22,7 +22,6 @@ import { NavigationRouteContext } from './NavigationRouteContext';
import { NavigationStateContext } from './NavigationStateContext';
import { PreventRemoveProvider } from './PreventRemoveProvider';
import { Screen } from './Screen';
import { SetNextStateContext } from './SetNextStateContext';
import {
type DefaultNavigatorOptions,
type EventMapBase,
Expand Down Expand Up @@ -444,18 +443,6 @@ export function useNavigationBuilder<

const previousRouteKeyList = previousRouteKeyListRef.current;

const { stateForNextRouteNamesChange, setStateForNextRouteNamesChange } =
React.useContext(SetNextStateContext);

const navigatorStateForNextRouteNamesChange =
stateForNextRouteNamesChange?.[navigatorKey] ?? null;

const setNavigatorStateForNextRouteNamesChange = useLatestCallback(
(state: PartialState<NavigationState>) => {
setStateForNextRouteNamesChange({ [navigatorKey]: state });
}
);

let state =
// If the state isn't initialized, or stale, use the state we initialized instead
// The state won't update until there's a change needed in the state we have initalized locally
Expand All @@ -470,6 +457,8 @@ export function useNavigationBuilder<
!isArrayEqual(state.routeNames, routeNames) ||
!isRecordEqual(routeKeyList, previousRouteKeyList)
) {
const navigatorStateForNextRouteNamesChange =
options.getStateForRouteNamesChange?.(state);
// When the list of route names change, the router should handle it to remove invalid routes
nextState = navigatorStateForNextRouteNamesChange
? // @ts-expect-error this is ok
Expand Down Expand Up @@ -658,7 +647,6 @@ export function useNavigationBuilder<
routeGetIdList,
},
emitter,
stateForNextRouteNamesChange: navigatorStateForNextRouteNamesChange,
});

const onRouteFocus = useOnRouteFocus({
Expand All @@ -679,7 +667,6 @@ export function useNavigationBuilder<
getState,
emitter,
router,
setStateForNextRouteNamesChange: setNavigatorStateForNextRouteNamesChange,
});

useFocusedListenersChildrenAdapter({
Expand Down
5 changes: 0 additions & 5 deletions packages/core/src/useNavigationHelpers.tsx
Expand Up @@ -3,7 +3,6 @@ import {
type NavigationAction,
type NavigationState,
type ParamListBase,
type PartialState,
type Router,
} from '@react-navigation/routers';
import * as React from 'react';
Expand All @@ -22,7 +21,6 @@ type Options<State extends NavigationState, Action extends NavigationAction> = {
getState: () => State;
emitter: NavigationEventEmitter<any>;
router: Router<State, Action>;
setStateForNextRouteNamesChange: (state: PartialState<State>) => void;
};

/**
Expand All @@ -40,7 +38,6 @@ export function useNavigationHelpers<
getState,
emitter,
router,
setStateForNextRouteNamesChange,
}: Options<State, Action>) {
const onUnhandledAction = React.useContext(UnhandledActionContext);
const parentNavigationHelpers = React.useContext(NavigationContext);
Expand Down Expand Up @@ -103,7 +100,6 @@ export function useNavigationHelpers<
return parentNavigationHelpers;
},
getState,
setStateForNextRouteNamesChange,
} as NavigationHelpers<ParamListBase, EventMap> & ActionHelpers;

return navigationHelpers;
Expand All @@ -115,6 +111,5 @@ export function useNavigationHelpers<
onUnhandledAction,
parentNavigationHelpers,
router,
setStateForNextRouteNamesChange,
]);
}
8 changes: 0 additions & 8 deletions packages/core/src/useOnAction.tsx
Expand Up @@ -26,7 +26,6 @@ type Options = {
beforeRemoveListeners: Record<string, ChildBeforeRemoveListener | undefined>;
routerConfigOptions: RouterConfigOptions;
emitter: NavigationEventEmitter<EventMapCore<any>>;
stateForNextRouteNamesChange: PartialState<NavigationState> | null;
};

/**
Expand All @@ -47,7 +46,6 @@ export function useOnAction({
beforeRemoveListeners,
routerConfigOptions,
emitter,
stateForNextRouteNamesChange,
}: Options) {
const {
onAction: onActionParent,
Expand All @@ -71,11 +69,6 @@ export function useOnAction({
action: NavigationAction,
visitedNavigators: Set<string> = new Set<string>()
) => {
if (stateForNextRouteNamesChange) {
console.warn(
'Other navigation was invoked before we handling the unhandled deeplink (requested by invoking handleOnNextRouteNamesChange)'
);
}
const state = getState();

// Since actions can bubble both up and down, they could come to the same navigator again
Expand Down Expand Up @@ -164,7 +157,6 @@ export function useOnAction({
onRouteFocusParent,
router,
setState,
stateForNextRouteNamesChange,
]
);

Expand Down
4 changes: 0 additions & 4 deletions packages/native/src/LinkingContext.tsx
Expand Up @@ -6,14 +6,10 @@ const MISSING_CONTEXT_ERROR = "Couldn't find a LinkingContext context.";

export const LinkingContext = React.createContext<{
options?: LinkingOptions<ParamListBase>;
lastUnhandledLinking: React.MutableRefObject<string | null | undefined>;
}>({
get options(): any {
throw new Error(MISSING_CONTEXT_ERROR);
},
get lastUnhandledLinking(): any {
throw new Error(MISSING_CONTEXT_ERROR);
},
});

LinkingContext.displayName = 'LinkingContext';
65 changes: 39 additions & 26 deletions packages/native/src/NavigationContainer.tsx
Expand Up @@ -23,6 +23,7 @@ import type {
LocaleDirection,
Theme,
} from './types';
import { UnhandledLinkingContext } from './UnhandledLinkingContext';
import { useBackButton } from './useBackButton';
import { useDocumentTitle } from './useDocumentTitle';
import { useLinking } from './useLinking';
Expand Down Expand Up @@ -87,7 +88,9 @@ function NavigationContainerInner(
useBackButton(refContainer);
useDocumentTitle(refContainer, documentTitle);

const lastUnhandledLinking = React.useRef<string | undefined>();
const [lastUnhandledLink, setLastUnhandledLink] = React.useState<
string | undefined
>();

const { getInitialState } = useLinking(
refContainer,
Expand All @@ -96,30 +99,38 @@ function NavigationContainerInner(
prefixes: [],
...linking,
},
lastUnhandledLinking
setLastUnhandledLink
);

const linkingContext = React.useMemo(
() => ({ options: linking, lastUnhandledLinking }),
[linking, lastUnhandledLinking]
const linkingContext = React.useMemo(() => ({ options: linking }), [linking]);

const unhandledLinkingContext = React.useMemo(
() => ({ lastUnhandledLink, setLastUnhandledLink }),
[lastUnhandledLink, setLastUnhandledLink]
);

const onReadyForLinkingHandling = useLatestCallback(() => {
// If the screen path matches lastUnhandledLinking, we do not track it
// If the screen path matches lastUnhandledLink, we do not track it
const path = refContainer.current?.getCurrentRoute()?.path;
if (path === linkingContext.lastUnhandledLinking.current) {
linkingContext.lastUnhandledLinking.current = undefined;
}
setLastUnhandledLink((previousLastUnhandledLink) => {
if (previousLastUnhandledLink === path) {
return undefined;
}
return previousLastUnhandledLink;
});
onReady?.();
});

const onStateChangeForLinkingHandling = useLatestCallback(
(state: Readonly<NavigationState> | undefined) => {
// If the screen path matches lastUnhandledLinking, we do not track it
// If the screen path matches lastUnhandledLink, we do not track it
const path = refContainer.current?.getCurrentRoute()?.path;
if (path === linkingContext.lastUnhandledLinking.current) {
linkingContext.lastUnhandledLinking.current = undefined;
}
setLastUnhandledLink((previousLastUnhandledLink) => {
if (previousLastUnhandledLink === path) {
return undefined;
}
return previousLastUnhandledLink;
});
onStateChange?.(state);
}
);
Expand Down Expand Up @@ -158,19 +169,21 @@ function NavigationContainerInner(

return (
<LocaleDirContext.Provider value={direction}>
<LinkingContext.Provider value={linkingContext}>
<ThemeProvider value={theme}>
<BaseNavigationContainer
{...rest}
onReady={onReadyForLinkingHandling}
onStateChange={onStateChangeForLinkingHandling}
initialState={
rest.initialState == null ? initialState : rest.initialState
}
ref={refContainer}
/>
</ThemeProvider>
</LinkingContext.Provider>
<UnhandledLinkingContext.Provider value={unhandledLinkingContext}>
<LinkingContext.Provider value={linkingContext}>
<ThemeProvider value={theme}>
<BaseNavigationContainer
{...rest}
onReady={onReadyForLinkingHandling}
onStateChange={onStateChangeForLinkingHandling}
initialState={
rest.initialState == null ? initialState : rest.initialState
}
ref={refContainer}
/>
</ThemeProvider>
</LinkingContext.Provider>
</UnhandledLinkingContext.Provider>
</LocaleDirContext.Provider>
);
}
Expand Down

0 comments on commit 5758b26

Please sign in to comment.