Skip to content

Commit

Permalink
feat: preloading for stack navigator (#11733)
Browse files Browse the repository at this point in the history
This is yet another follow-up of #11702. Now, it is about implementing
preloading for the stack navigator.

The first essential part is merged from #11727 and is about creating
temporary descriptors for routes which are not yet in the navigation
state, but only preloaded.

The remaining part is adjusting the logic for the preloading flow.
Particularly, a lot of changes were needed for handling cards, where
`gesture` is `undefined` and making the animation run a later for those
routes (not on the initial mount, but on navigating). The remaining
changes are not critical.

Precise comments are included in the comments.

Please review the tests and the video below.


https://github.com/react-navigation/react-navigation/assets/25709300/830a7dfc-cf6a-4117-b38d-7f6326934af1

---------

Co-authored-by: Satyajit Sahoo <satyajit.happy@gmail.com>
  • Loading branch information
osdnk and satya164 committed Dec 13, 2023
1 parent ad7c703 commit 14fa6df
Show file tree
Hide file tree
Showing 10 changed files with 473 additions and 195 deletions.
117 changes: 117 additions & 0 deletions example/src/Screens/StackPreloadFlow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Button } from '@react-navigation/elements';
import {
createStackNavigator,
type StackScreenProps,
} from '@react-navigation/stack';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';

type PreloadStackParams = {
Home: undefined;
Details: undefined;
Profile: undefined;
};

const DetailsScreen = ({
navigation,
}: StackScreenProps<PreloadStackParams, 'Details'>) => {
const [loadingCountdown, setLoadingCountdown] = useState(3);
useEffect(() => {
const interval = setInterval(() => {
setLoadingCountdown((loadingCountdown) => {
if (loadingCountdown === 1) {
clearInterval(interval);
}
return loadingCountdown - 1;
});
}, 1000);
}, []);

return (
<View style={styles.content}>
<Text style={[styles.text, styles.countdown]}>
{loadingCountdown > 0 && loadingCountdown}
</Text>
<Text style={styles.text}>
{loadingCountdown === 0 ? 'Loaded!' : 'Loading...'}
</Text>
<Button onPress={navigation.goBack} style={styles.button}>
Back to home
</Button>
<Button
onPress={() => navigation.navigate('Profile')}
style={styles.button}
>
Go to Profile
</Button>
</View>
);
};

const ProfileScreen = ({
navigation,
}: StackScreenProps<PreloadStackParams, 'Profile'>) => {
return (
<View style={styles.content}>
<Text style={styles.text}>Profile</Text>
<Button onPress={navigation.goBack} style={styles.button}>
Back to home
</Button>
</View>
);
};

const HomeScreen = ({
navigation,
}: StackScreenProps<PreloadStackParams, 'Home'>) => {
const { navigate, preload, removePreload } = navigation;

return (
<View style={styles.content}>
<Button onPress={() => preload('Details')} style={styles.button}>
Preload Details
</Button>
<Button onPress={() => preload('Profile')} style={styles.button}>
Preload Profile
</Button>
<Button onPress={() => navigate('Details')} style={styles.button}>
Navigate Details
</Button>
<Button onPress={() => removePreload('Details')} style={styles.button}>
Remove Details preload
</Button>
</View>
);
};

const SimpleStack = createStackNavigator<PreloadStackParams>();

export function StackPreloadFlow() {
return (
<SimpleStack.Navigator screenOptions={{ headerShown: false }}>
<SimpleStack.Screen name="Home" component={HomeScreen} />
<SimpleStack.Screen name="Details" component={DetailsScreen} />
<SimpleStack.Screen name="Profile" component={ProfileScreen} />
</SimpleStack.Navigator>
);
}

const styles = StyleSheet.create({
content: {
flex: 1,
padding: 16,
justifyContent: 'center',
},
button: {
margin: 8,
},
text: {
textAlign: 'center',
margin: 8,
},
countdown: {
fontSize: 24,
minHeight: 32,
},
});
5 changes: 5 additions & 0 deletions example/src/screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { NativeStackHeaderCustomization } from './Screens/NativeStackHeaderCusto
import { NativeStackPreventRemove } from './Screens/NativeStackPreventRemove';
import { SimpleStack } from './Screens/SimpleStack';
import { StackHeaderCustomization } from './Screens/StackHeaderCustomization';
import { StackPreloadFlow } from './Screens/StackPreloadFlow';
import { StackPreventRemove } from './Screens/StackPreventRemove';
import { StackTransparent } from './Screens/StackTransparent';
import { StaticScreen } from './Screens/Static';
Expand Down Expand Up @@ -117,6 +118,10 @@ export const SCREENS = {
title: 'Linking with authentication flow',
component: LinkingScreen,
},
PreloadFlowStack: {
title: 'Preloading flow for Stack',
component: StackPreloadFlow,
},
PreloadFlowTab: {
title: 'Preloading flow for Bottom Tabs',
component: TabPreloadFlow,
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/useDescriptors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ export function useDescriptors<
const { base, navigations } = useNavigationCache<
State,
ScreenOptions,
EventMap
EventMap,
ActionHelpers
>({
state,
getState,
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/useNavigationCache.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function useNavigationCache<
State extends NavigationState,
ScreenOptions extends {},
EventMap extends Record<string, any>,
ActionHelpers extends Record<string, () => void>,
>({
state,
getState,
Expand All @@ -71,7 +72,8 @@ export function useNavigationCache<
State,
ScreenOptions,
EventMap
> => {
> &
ActionHelpers => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { emit, ...rest } = navigation;

Expand All @@ -93,7 +95,7 @@ export function useNavigationCache<
return acc;
},
{}
);
) as ActionHelpers;

return {
...rest,
Expand All @@ -109,7 +111,6 @@ export function useNavigationCache<
// Event listeners are not supported for placeholder screens
},
dispatch,
// @ts-expect-error: too much work to fix the types for now
getParent: (id?: string) => {
if (id !== undefined && id === rest.getId()) {
return base;
Expand Down
131 changes: 131 additions & 0 deletions packages/stack/src/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
createNavigationContainerRef,
NavigationContainer,
type ParamListBase,
useFocusEffect,
useIsFocused,
} from '@react-navigation/native';
import { act, fireEvent, render } from '@testing-library/react-native';
import * as React from 'react';
Expand Down Expand Up @@ -39,3 +42,131 @@ it('renders a stack navigator with screens', async () => {

expect(queryByText('Screen B')).not.toBeNull();
});

type StackParamList = {
A: undefined;
B: undefined;
};

it('handles screens preloading', async () => {
const Stack = createStackNavigator<StackParamList>();

const navigation = createNavigationContainerRef<StackParamList>();

const { queryByText } = render(
<NavigationContainer ref={navigation}>
<Stack.Navigator>
<Stack.Screen name="A">{() => null}</Stack.Screen>
<Stack.Screen name="B">{() => <Text>Screen B</Text>}</Stack.Screen>
</Stack.Navigator>
</NavigationContainer>
);

expect(queryByText('Screen B', { includeHiddenElements: true })).toBeNull();
act(() => navigation.preload('B'));
expect(
queryByText('Screen B', { includeHiddenElements: true })
).not.toBeNull();
act(() => navigation.removePreload('B'));
expect(queryByText('Screen B', { includeHiddenElements: true })).toBeNull();
});

it('runs focus effect on focus change on preloaded route', () => {
const focusEffect = jest.fn();
const focusEffectCleanup = jest.fn();

const Test = () => {
const onFocus = React.useCallback(() => {
focusEffect();

return focusEffectCleanup;
}, []);

useFocusEffect(onFocus);

return null;
};

const Stack = createStackNavigator();

const navigation = React.createRef<any>();

render(
<NavigationContainer ref={navigation}>
<Stack.Navigator>
<Stack.Screen name="first">{() => null}</Stack.Screen>
<Stack.Screen name="second" component={Test} />
</Stack.Navigator>
</NavigationContainer>
);

expect(focusEffect).not.toHaveBeenCalled();
expect(focusEffectCleanup).not.toHaveBeenCalled();

act(() => navigation.current.preload('second'));
act(() => navigation.current.removePreload('second'));
act(() => navigation.current.preload('second'));

expect(focusEffect).not.toHaveBeenCalled();
expect(focusEffectCleanup).not.toHaveBeenCalled();

act(() => navigation.current.navigate('second'));

expect(focusEffect).toHaveBeenCalledTimes(1);
expect(focusEffectCleanup).not.toHaveBeenCalled();

act(() => navigation.current.navigate('first'));

expect(focusEffect).toHaveBeenCalledTimes(1);
expect(focusEffectCleanup).toHaveBeenCalledTimes(1);
});

it('renders correct focus state with preloading', () => {
const Test = () => {
const isFocused = useIsFocused();

return (
<>
<Text>Test Screen</Text>
<Text>{isFocused ? 'focused' : 'unfocused'}</Text>
</>
);
};

const Stack = createStackNavigator();

const navigation = React.createRef<any>();

const { queryByText } = render(
<NavigationContainer ref={navigation}>
<Stack.Navigator>
<Stack.Screen name="first">{() => null}</Stack.Screen>
<Stack.Screen name="second" component={Test} />
</Stack.Navigator>
</NavigationContainer>
);

expect(
queryByText('Test Screen', { includeHiddenElements: true })
).toBeNull();

act(() => navigation.current.preload('second'));

expect(
queryByText('Test Screen', { includeHiddenElements: true })
).not.toBeNull();

expect(
queryByText('unfocused', { includeHiddenElements: true })
).not.toBeNull();

act(() => navigation.current.navigate('second'));

expect(
queryByText('focused', { includeHiddenElements: true })
).not.toBeNull();

act(() => navigation.current.navigate('first'));

expect(queryByText('focused', { includeHiddenElements: true })).toBeNull();
});
3 changes: 2 additions & 1 deletion packages/stack/src/navigators/createStackNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function StackNavigator({
}: Props) {
const { direction } = useLocale();

const { state, descriptors, navigation, NavigationContent } =
const { state, describe, descriptors, navigation, NavigationContent } =
useNavigationBuilder<
StackNavigationState<ParamListBase>,
StackRouterOptions,
Expand Down Expand Up @@ -90,6 +90,7 @@ function StackNavigator({
{...rest}
direction={direction}
state={state}
describe={describe}
descriptors={descriptors}
navigation={navigation}
/>
Expand Down
Loading

0 comments on commit 14fa6df

Please sign in to comment.