Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: automatically infer types for navigation in options, listeners etc. #11883

Merged
merged 19 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions example/__typechecks__/common.check.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
import {
createStackNavigator,
type StackNavigationOptions,
type StackOptionsArgs,
type StackScreenProps,
} from '@react-navigation/stack';
import { expectTypeOf } from 'expect-type';
Expand Down Expand Up @@ -311,6 +312,79 @@ const SecondStack = createStackNavigator<SecondParamList>();
component={(_: { foo: number }) => <></>}
/>;

/**
* Check for options type in Screen config
*/
<SecondStack.Screen
name="HasParams1"
component={() => <></>}
options={{
headerShown: false,
}}
/>;

<SecondStack.Screen
name="HasParams1"
component={() => <></>}
options={{
// @ts-expect-error
headerShown: 13,
}}
/>;

<SecondStack.Screen
name="HasParams1"
component={() => <></>}
options={() => ({
headerShown: false,
})}
/>;

<SecondStack.Screen
name="HasParams1"
component={() => <></>}
// @ts-expect-error
options={() => ({
headerShown: 13,
})}
/>;

<SecondStack.Screen
name="HasParams1"
component={() => <></>}
options={({ route, navigation, theme }) => {
expectTypeOf(route.name).toEqualTypeOf<'HasParams1'>();
expectTypeOf(route.params).toEqualTypeOf<Readonly<{ id: string }>>();
expectTypeOf(navigation.getState().type).toMatchTypeOf<'stack'>();
expectTypeOf(navigation.push)
.parameter(0)
.toEqualTypeOf<keyof SecondParamList>();
expectTypeOf(theme).toMatchTypeOf<ReactNavigation.Theme>();

return {};
}}
/>;

<SecondStack.Screen
name="HasParams1"
component={() => <></>}
options={({
route,
navigation,
theme,
}: StackOptionsArgs<SecondParamList, 'HasParams1'>) => {
expectTypeOf(route.name).toEqualTypeOf<'HasParams1'>();
expectTypeOf(route.params).toEqualTypeOf<Readonly<{ id: string }>>();
expectTypeOf(navigation.getState().type).toMatchTypeOf<'stack'>();
expectTypeOf(navigation.push)
.parameter(0)
.toEqualTypeOf<keyof SecondParamList>();
expectTypeOf(theme).toMatchTypeOf<ReactNavigation.Theme>();

return {};
}}
/>;

/**
* Check for listeners type in Screen config
*/
Expand All @@ -334,6 +408,39 @@ const SecondStack = createStackNavigator<SecondParamList>();
}}
/>;

<SecondStack.Screen
name="HasParams1"
component={() => <></>}
listeners={({ route, navigation }) => {
expectTypeOf(route.name).toEqualTypeOf<'HasParams1'>();
expectTypeOf(route.params).toEqualTypeOf<Readonly<{ id: string }>>();
expectTypeOf(navigation.getState().type).toMatchTypeOf<'stack'>();
expectTypeOf(navigation.push)
.parameter(0)
.toEqualTypeOf<keyof SecondParamList>();

return {};
}}
/>;

<SecondStack.Screen
name="HasParams1"
component={() => <></>}
listeners={({
route,
navigation,
}: StackScreenProps<SecondParamList, 'HasParams1'>) => {
expectTypeOf(route.name).toEqualTypeOf<'HasParams1'>();
expectTypeOf(route.params).toEqualTypeOf<Readonly<{ id: string }>>();
expectTypeOf(navigation.getState().type).toMatchTypeOf<'stack'>();
expectTypeOf(navigation.push)
.parameter(0)
.toEqualTypeOf<keyof SecondParamList>();

return {};
}}
/>;

/**
* Check for errors with `navigation.navigate`
*/
Expand Down Expand Up @@ -376,3 +483,18 @@ export const ThirdScreen = ({
// @ts-expect-error
if (ScreenName === 'NoParams') navigation.navigate(ScreenName, { id: '123' });
};

/**
* Check for navigator ID
*/
type FourthParamList = {
HasParams1: { id: string };
HasParams2: { user: string };
NoParams: undefined;
};

const FourthStack = createStackNavigator<FourthParamList, 'MyID'>();

expectTypeOf(FourthStack.Navigator).parameter(0).toMatchTypeOf<{
id: 'MyID';
}>();
98 changes: 98 additions & 0 deletions example/__typechecks__/static.check.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,21 @@ createBottomTabNavigator({
screens: {},
});

createBottomTabNavigator({
screenOptions: () => ({
tabBarActiveTintColor: 'tomato',
}),
screens: {},
});

createBottomTabNavigator({
// @ts-expect-error
screenOptions: () => ({
tabBarActiveTintColor: 42,
}),
screens: {},
});

/**
* Infer screen options
*/
Expand All @@ -179,6 +194,29 @@ createBottomTabNavigator({
},
});

createBottomTabNavigator({
screens: {
Test: {
screen: () => null,
options: () => ({
tabBarActiveTintColor: 'tomato',
}),
},
},
});

createBottomTabNavigator({
screens: {
Test: {
screen: () => null,
// @ts-expect-error
options: () => ({
tabBarActiveTintColor: 42,
}),
},
},
});

createBottomTabNavigator({
screens: {
Test: {
Expand All @@ -190,6 +228,66 @@ createBottomTabNavigator({
},
});

/**
* Have correct type for screen options callback
*/
createBottomTabNavigator({
screenOptions: ({ route, navigation, theme }) => {
expectTypeOf(route.name).toMatchTypeOf<string>();
expectTypeOf(navigation.getState().type).toMatchTypeOf<'tab'>();
expectTypeOf(navigation.jumpTo).toMatchTypeOf<Function>();
expectTypeOf(theme).toMatchTypeOf<ReactNavigation.Theme>();

return {};
},
screens: {},
});

createBottomTabNavigator({
screens: {
Test: {
screen: () => null,
options: ({ route, navigation, theme }) => {
expectTypeOf(route.name).toMatchTypeOf<string>();
expectTypeOf(navigation.getState().type).toMatchTypeOf<'tab'>();
expectTypeOf(navigation.jumpTo).toMatchTypeOf<Function>();
expectTypeOf(theme).toMatchTypeOf<ReactNavigation.Theme>();

return {};
},
},
},
});

/**
* Have correct type for listeners callback
*/
createBottomTabNavigator({
screenListeners: ({ route, navigation }) => {
expectTypeOf(route.name).toMatchTypeOf<string>();
expectTypeOf(navigation.getState().type).toMatchTypeOf<'tab'>();
expectTypeOf(navigation.jumpTo).toMatchTypeOf<Function>();

return {};
},
screens: {},
});

createBottomTabNavigator({
screens: {
Test: {
screen: () => null,
listeners: ({ navigation, route }) => {
expectTypeOf(route.name).toMatchTypeOf<string>();
expectTypeOf(navigation.getState().type).toMatchTypeOf<'tab'>();
expectTypeOf(navigation.jumpTo).toMatchTypeOf<Function>();

return {};
},
},
},
});

/**
* Requires `screens` to be defined
*/
Expand Down
9 changes: 8 additions & 1 deletion example/src/Screens/NativeStackHeaderCustomization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Button, HeaderButton } from '@react-navigation/elements';
import type { PathConfigMap } from '@react-navigation/native';
import {
createNativeStackNavigator,
type NativeStackOptionsArgs,
type NativeStackScreenProps,
} from '@react-navigation/native-stack';
import * as React from 'react';
Expand Down Expand Up @@ -120,7 +121,13 @@ export function NativeStackHeaderCustomization() {
<Stack.Screen
name="Article"
component={ArticleScreen}
options={({ route, navigation }) => ({
options={({
route,
navigation,
}: NativeStackOptionsArgs<
NativeHeaderCustomizationStackParams,
'Article'
>) => ({
title: `Article by ${route.params?.author ?? 'Unknown'}`,
headerTintColor: 'white',
headerTitle: ({ tintColor }) => (
Expand Down
18 changes: 10 additions & 8 deletions packages/bottom-tabs/src/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
import {
createNavigationContainerRef,
NavigationContainer,
type ParamListBase,
} from '@react-navigation/native';
import { act, fireEvent, render } from '@testing-library/react-native';
import * as React from 'react';
import { Animated, Button, Text, View } from 'react-native';

import { type BottomTabScreenProps, createBottomTabNavigator } from '../index';

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

it('renders a bottom tab navigator with screens', async () => {
// @ts-expect-error: incomplete mock for testing
jest.spyOn(Animated, 'timing').mockImplementation(() => ({
start: (callback) => callback?.({ finished: true }),
}));

const Test = ({ route, navigation }: BottomTabScreenProps<ParamListBase>) => (
const Test = ({
route,
navigation,
}: BottomTabScreenProps<BottomTabParamList>) => (
<View>
<Text>Screen {route.name}</Text>
<Button onPress={() => navigation.navigate('A')} title="Go to A" />
<Button onPress={() => navigation.navigate('B')} title="Go to B" />
</View>
);

const Tab = createBottomTabNavigator();
const Tab = createBottomTabNavigator<BottomTabParamList>();

const { findByText, queryByText } = render(
<NavigationContainer>
Expand All @@ -42,11 +49,6 @@ it('renders a bottom tab navigator with screens', async () => {
expect(queryByText('Screen B')).not.toBeNull();
});

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

it('handles screens preloading', async () => {
const Tab = createBottomTabNavigator<BottomTabParamList>();

Expand Down
2 changes: 1 addition & 1 deletion packages/bottom-tabs/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,6 @@ export type {
BottomTabNavigationEventMap,
BottomTabNavigationOptions,
BottomTabNavigationProp,
BottomTabScreenOptions,
BottomTabOptionsArgs,
BottomTabScreenProps,
} from './types';
Loading
Loading