Skip to content

Commit

Permalink
feat: implement usePreventRemove hook (#10682)
Browse files Browse the repository at this point in the history
As we still polishing the API we're marking the hook for now as UNSTABLE_usePreventRemove.

**Motivation**

This PR introduces a new hook - usePreventRemove - used for preventing going back in react-navigation. This hook would allow supporting preventing of screen removal in @react-navigation/native-stack which is one of the limitations of current API.

The usePreventRemove hook would take two arguments:

preventRemove - boolean indicating whether to prevent screen from being removed
callback - function which is executed when screen was prevented from being removed


Co-authored-by: Satyajit Sahoo <satyajit.happy@gmail.com>
  • Loading branch information
kacperkapusciak and satya164 committed Jul 28, 2022
1 parent d19987b commit 7411516
Show file tree
Hide file tree
Showing 16 changed files with 1,479 additions and 38 deletions.
174 changes: 174 additions & 0 deletions example/src/Screens/NativeStackPreventRemove.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { UNSTABLE_usePreventRemove } from '@react-navigation/core';
import {
CommonActions,
ParamListBase,
useTheme,
} from '@react-navigation/native';
import {
createNativeStackNavigator,
NativeStackScreenProps,
} from '@react-navigation/native-stack';
import * as React from 'react';
import {
Alert,
Platform,
ScrollView,
StyleSheet,
TextInput,
View,
} from 'react-native';
import { Button } from 'react-native-paper';

import Article from '../Shared/Article';

type PreventRemoveParams = {
Article: { author: string };
Input: undefined;
};

const scrollEnabled = Platform.select({ web: true, default: false });

const ArticleScreen = ({
navigation,
route,
}: NativeStackScreenProps<PreventRemoveParams, 'Article'>) => {
return (
<ScrollView>
<View style={styles.buttons}>
<Button
mode="contained"
onPress={() => navigation.push('Input')}
style={styles.button}
>
Push Input
</Button>
<Button
mode="outlined"
onPress={() => navigation.popToTop()}
style={styles.button}
>
Pop to top
</Button>
</View>
<Article
author={{ name: route.params?.author ?? 'Unknown' }}
scrollEnabled={scrollEnabled}
/>
</ScrollView>
);
};

const InputScreen = ({
navigation,
}: NativeStackScreenProps<PreventRemoveParams, 'Input'>) => {
const [text, setText] = React.useState('');
const { colors } = useTheme();

const hasUnsavedChanges = Boolean(text);

UNSTABLE_usePreventRemove(hasUnsavedChanges, ({ data }) => {
if (Platform.OS === 'web') {
const discard = confirm(
'You have unsaved changes. Discard them and leave the screen?'
);
if (discard) {
navigation.dispatch(data.action);
}
} else {
Alert.alert(
'Discard changes?',
'You have unsaved changes. Discard them and leave the screen?',
[
{ text: "Don't leave", style: 'cancel', onPress: () => {} },
{
text: 'Discard',
style: 'destructive',
onPress: () => navigation.dispatch(data.action),
},
]
);
}
});

return (
<View style={styles.content}>
<TextInput
autoFocus
style={[
styles.input,
{ backgroundColor: colors.card, color: colors.text },
]}
value={text}
placeholder="Type something…"
onChangeText={setText}
/>
<Button
mode="outlined"
color="tomato"
onPress={() =>
navigation.dispatch({
...CommonActions.goBack(),
payload: { confirmed: true },
})
}
style={styles.button}
>
Discard and go back
</Button>
<Button
mode="outlined"
onPress={() => navigation.push('Article', { author: text })}
style={styles.button}
>
Push Article
</Button>
</View>
);
};

const Stack = createNativeStackNavigator<PreventRemoveParams>();

type Props = NativeStackScreenProps<ParamListBase>;

export default function StackScreen({ navigation }: Props) {
React.useLayoutEffect(() => {
navigation.setOptions({
headerShown: false,
});
}, [navigation]);

return (
<Stack.Navigator>
<Stack.Screen
name="Input"
component={InputScreen}
options={{
presentation: 'modal',
}}
/>
<Stack.Screen name="Article" component={ArticleScreen} />
</Stack.Navigator>
);
}

const styles = StyleSheet.create({
content: {
flex: 1,
padding: 16,
},
input: {
margin: 8,
padding: 10,
borderRadius: 3,
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(0, 0, 0, 0.08)',
},
buttons: {
flexDirection: 'row',
flexWrap: 'wrap',
padding: 8,
},
button: {
margin: 8,
},
});
File renamed without changes.
13 changes: 9 additions & 4 deletions example/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ import MixedStack from './Screens/MixedStack';
import ModalStack from './Screens/ModalStack';
import NativeStack from './Screens/NativeStack';
import NativeStackHeaderCustomization from './Screens/NativeStackHeaderCustomization';
import NativeStackPreventRemove from './Screens/NativeStackPreventRemove';
import NotFound from './Screens/NotFound';
import PreventRemove from './Screens/PreventRemove';
import SimpleStack from './Screens/SimpleStack';
import StackHeaderCustomization from './Screens/StackHeaderCustomization';
import StackPreventRemove from './Screens/StackPreventRemove';
import StackTransparent from './Screens/StackTransparent';
import SettingsItem from './Shared/SettingsItem';

Expand Down Expand Up @@ -128,9 +129,13 @@ const SCREENS = {
title: 'Auth Flow',
component: AuthFlow,
},
PreventRemove: {
title: 'Prevent removing screen',
component: PreventRemove,
StackPreventRemove: {
title: 'Prevent removing screen in Stack',
component: StackPreventRemove,
},
NativeStackPreventRemove: {
title: 'Prevent removing screen in Native Stack',
component: NativeStackPreventRemove,
},
LinkComponent: {
title: '<Link />',
Expand Down
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"escape-string-regexp": "^4.0.0",
"nanoid": "^3.1.23",
"query-string": "^7.0.0",
"react-is": "^16.13.0"
"react-is": "^16.13.0",
"use-latest-callback": "^0.1.4"
},
"devDependencies": {
"@testing-library/react-native": "^7.2.0",
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/PreventRemoveContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';

/**
* A type of an object that have a route key as an object key
* and a value whether to prevent that route.
*/
export type PreventedRoutes = Record<string, { preventRemove: boolean }>;

const PreventRemoveContext = React.createContext<
| {
preventedRoutes: PreventedRoutes;
setPreventRemove: (
id: string,
routeKey: string,
preventRemove: boolean
) => void;
}
| undefined
>(undefined);

export default PreventRemoveContext;
126 changes: 126 additions & 0 deletions packages/core/src/PreventRemoveProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { nanoid } from 'nanoid/non-secure';
import * as React from 'react';
import useLatestCallback from 'use-latest-callback';

import NavigationHelpersContext from './NavigationHelpersContext';
import NavigationRouteContext from './NavigationRouteContext';
import PreventRemoveContext, { PreventedRoutes } from './PreventRemoveContext';

type Props = {
children: React.ReactNode;
};

type PreventedRoutesMap = Map<
string,
{
routeKey: string;
preventRemove: boolean;
}
>;

/**
* Util function to transform map of prevented routes to a simpler object.
*/
const transformPreventedRoutes = (
preventedRoutesMap: PreventedRoutesMap
): PreventedRoutes => {
const preventedRoutesToTransform = [...preventedRoutesMap.values()];

const preventedRoutes = preventedRoutesToTransform.reduce<PreventedRoutes>(
(acc, { routeKey, preventRemove }) => {
acc[routeKey] = {
preventRemove: acc[routeKey]?.preventRemove || preventRemove,
};
return acc;
},
{}
);

return preventedRoutes;
};

/**
* Component used for managing which routes have to be prevented from removal in native-stack.
*/
export default function PreventRemoveProvider({ children }: Props) {
const [parentId] = React.useState(() => nanoid());
const [preventedRoutesMap, setPreventedRoutesMap] =
React.useState<PreventedRoutesMap>(new Map());

const navigation = React.useContext(NavigationHelpersContext);
const route = React.useContext(NavigationRouteContext);

const preventRemoveContextValue = React.useContext(PreventRemoveContext);
// take `setPreventRemove` from parent context - if exist it means we're in a nested context
const setParentPrevented = preventRemoveContextValue?.setPreventRemove;

const setPreventRemove = useLatestCallback(
(id: string, routeKey: string, preventRemove: boolean): void => {
if (
preventRemove &&
(navigation == null ||
navigation
?.getState()
.routes.every((route) => route.key !== routeKey))
) {
throw new Error(
`Couldn't find a route with the key ${routeKey}. Is your component inside NavigationContent?`
);
}

setPreventedRoutesMap((prevPrevented) => {
// values haven't changed - do nothing
if (
routeKey === prevPrevented.get(id)?.routeKey &&
preventRemove === prevPrevented.get(id)?.preventRemove
) {
return prevPrevented;
}

const nextPrevented = new Map(prevPrevented);

if (preventRemove) {
nextPrevented.set(id, {
routeKey,
preventRemove,
});
} else {
nextPrevented.delete(id);
}

return nextPrevented;
});
}
);

const isPrevented = [...preventedRoutesMap.values()].some(
({ preventRemove }) => preventRemove
);

React.useEffect(() => {
if (route?.key !== undefined && setParentPrevented !== undefined) {
// when route is defined (and setParentPrevented) it means we're in a nested stack
// route.key then will be the route key of parent
setParentPrevented(parentId, route.key, isPrevented);
return () => {
setParentPrevented(parentId, route.key, false);
};
}

return;
}, [parentId, isPrevented, route?.key, setParentPrevented]);

const value = React.useMemo(
() => ({
setPreventRemove,
preventedRoutes: transformPreventedRoutes(preventedRoutesMap),
}),
[setPreventRemove, preventedRoutesMap]
);

return (
<PreventRemoveContext.Provider value={value}>
{children}
</PreventRemoveContext.Provider>
);
}

0 comments on commit 7411516

Please sign in to comment.