diff --git a/example/StackNavigator.tsx b/example/StackNavigator.tsx index 677110fc..16ade11d 100644 --- a/example/StackNavigator.tsx +++ b/example/StackNavigator.tsx @@ -8,6 +8,7 @@ import { CommonAction, ParamListBase, Router, + BaseRouter, createNavigator, } from '../src/index'; @@ -28,9 +29,8 @@ type Action = | { type: 'POP_TO_TOP' }; export type StackNavigationProp< - ParamList extends ParamListBase, - RouteName extends keyof ParamList = string -> = NavigationProp & { + ParamList extends ParamListBase +> = NavigationProp & { /** * Push a new screen onto the stack. * @@ -55,6 +55,7 @@ export type StackNavigationProp< }; const StackRouter: Router = { + ...BaseRouter, getInitialState({ routeNames, initialRouteName = routeNames[0], @@ -88,14 +89,6 @@ const StackRouter: Router = { return state; }, - getStateForRouteNamesChange(state, { routeNames }) { - return { - ...state, - routeNames, - routes: state.routes.filter(route => routeNames.includes(route.name)), - }; - }, - getStateForRouteFocus(state, key) { const index = state.routes.findIndex(r => r.key === key); @@ -246,18 +239,10 @@ const StackRouter: Router = { } default: - return null; + return BaseRouter.getStateForAction(state, action); } }, - shouldActionPropagateToChildren(action) { - return action.type === 'NAVIGATE'; - }, - - shouldActionChangeFocus(action) { - return action.type === 'NAVIGATE'; - }, - actionCreators: { push(name: string, params?: object) { return { type: 'PUSH', payload: { name, params } }; diff --git a/example/TabNavigator.tsx b/example/TabNavigator.tsx index 07c1e2d6..bbef500b 100644 --- a/example/TabNavigator.tsx +++ b/example/TabNavigator.tsx @@ -10,6 +10,7 @@ import { Router, createNavigator, TargetRoute, + BaseRouter, } from '../src/index'; type Props = { @@ -22,10 +23,9 @@ type Action = { payload: { name?: string; key?: string; params?: object }; }; -export type TabNavigationProp< - ParamList extends ParamListBase, - RouteName extends keyof ParamList = string -> = NavigationProp & { +export type TabNavigationProp = NavigationProp< + ParamList +> & { /** * Jump to an existing tab. * @@ -168,7 +168,7 @@ const TabRouter: Router = { return null; default: - return null; + return BaseRouter.getStateForAction(state, action); } }, diff --git a/example/index.tsx b/example/index.tsx index a0c7bc8e..34f5dd62 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -4,7 +4,7 @@ import { NavigationContainer, CompositeNavigationProp, PartialState, - NavigationHelpers, + NavigationProp, RouteProp, } from '../src'; import StackNavigator, { StackNavigationProp } from './StackNavigator'; @@ -30,8 +30,8 @@ const First = ({ route, }: { navigation: CompositeNavigationProp< - StackNavigationProp, - NavigationHelpers + StackNavigationProp, + NavigationProp >; route: RouteProp; }) => ( @@ -62,8 +62,8 @@ const Second = ({ navigation, }: { navigation: CompositeNavigationProp< - StackNavigationProp, - NavigationHelpers + StackNavigationProp, + NavigationProp >; }) => (
@@ -84,7 +84,7 @@ const Fourth = ({ navigation, }: { navigation: CompositeNavigationProp< - TabNavigationProp, + TabNavigationProp, StackNavigationProp >; }) => ( @@ -109,7 +109,7 @@ const Fifth = ({ navigation, }: { navigation: CompositeNavigationProp< - TabNavigationProp, + TabNavigationProp, StackNavigationProp >; }) => ( diff --git a/src/BaseActions.tsx b/src/BaseActions.tsx index d5d20b00..b4db71cc 100644 --- a/src/BaseActions.tsx +++ b/src/BaseActions.tsx @@ -13,6 +13,10 @@ export type Action = | { type: 'RESET'; payload: PartialState & { key?: string }; + } + | { + type: 'SET_PARAMS'; + payload: { name?: string; key?: string; params?: object }; }; export function goBack(): Action { @@ -20,17 +24,17 @@ export function goBack(): Action { } export function navigate(target: TargetRoute, params?: object): Action { - if ( - (target.hasOwnProperty('key') && target.hasOwnProperty('name')) || - (!target.hasOwnProperty('key') && !target.hasOwnProperty('name')) - ) { - throw new Error( - 'While calling navigate you need to specify either name or key' - ); - } if (typeof target === 'string') { return { type: 'NAVIGATE', payload: { name: target, params } }; } else { + if ( + (target.hasOwnProperty('key') && target.hasOwnProperty('name')) || + (!target.hasOwnProperty('key') && !target.hasOwnProperty('name')) + ) { + throw new Error( + 'While calling navigate you need to specify either name or key' + ); + } return { type: 'NAVIGATE', payload: { ...target, params } }; } } @@ -42,3 +46,20 @@ export function replace(name: string, params?: object): Action { export function reset(state: PartialState & { key?: string }): Action { return { type: 'RESET', payload: state }; } + +export function setParams( + params: object, + target: { name?: string; key?: string } +): Action { + if ( + target && + ((target.hasOwnProperty('key') && target.hasOwnProperty('name')) || + (!target.hasOwnProperty('key') && !target.hasOwnProperty('name'))) + ) { + throw new Error( + 'While calling setState with given second param you need to specify either name or key' + ); + } + + return { type: 'SET_PARAMS', payload: { params, ...target } }; +} diff --git a/src/BaseRouter.tsx b/src/BaseRouter.tsx new file mode 100644 index 00000000..af14b02b --- /dev/null +++ b/src/BaseRouter.tsx @@ -0,0 +1,49 @@ +import { CommonAction, Router } from './types'; + +const BaseRouter: Omit< + Omit, 'getInitialState'>, + 'getRehydratedState' +> = { + getStateForAction(state, action) { + switch (action.type) { + case 'SET_PARAMS': + return { + ...state, + routes: state.routes.map(r => + r.key === action.payload.key || r.name === action.payload.name + ? { ...r, params: { ...r.params, ...action.payload.params } } + : r + ), + }; + default: + return null; + } + }, + + getStateForRouteNamesChange(state, { routeNames }) { + return { + ...state, + routeNames, + routes: state.routes.filter(route => routeNames.includes(route.name)), + }; + }, + + getStateForRouteFocus(state, key) { + const index = state.routes.findIndex(r => r.key === key); + + if (index === -1 || index === state.index) { + return state; + } + + return { ...state, index }; + }, + shouldActionPropagateToChildren(action) { + return action.type === 'NAVIGATE'; + }, + + shouldActionChangeFocus(action) { + return action.type === 'NAVIGATE'; + }, +}; + +export default BaseRouter; diff --git a/src/NavigationBuilderContext.tsx b/src/NavigationBuilderContext.tsx index 4b7bc4bf..787a9935 100644 --- a/src/NavigationBuilderContext.tsx +++ b/src/NavigationBuilderContext.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { NavigationHelpers, NavigationAction } from './types'; +import { NavigationProp, NavigationAction } from './types'; export type ChildActionListener = ( action: NavigationAction, @@ -7,7 +7,7 @@ export type ChildActionListener = ( ) => boolean; const NavigationBuilderContext = React.createContext<{ - navigation?: NavigationHelpers; + navigation?: NavigationProp; onAction?: (action: NavigationAction, sourceNavigatorKey?: string) => boolean; addActionListener?: (listener: ChildActionListener) => void; removeActionListener?: (listener: ChildActionListener) => void; diff --git a/src/SceneView.tsx b/src/SceneView.tsx index 359c6a7d..771029c1 100644 --- a/src/SceneView.tsx +++ b/src/SceneView.tsx @@ -4,14 +4,15 @@ import StaticContainer from './StaticContainer'; import { Route, NavigationState, - NavigationHelpers, + NavigationProp, RouteConfig, + TargetRoute, } from './types'; import EnsureSingleNavigator from './EnsureSingleNavigator'; type Props = { screen: RouteConfig; - navigation: NavigationHelpers; + navigation: NavigationProp; route: Route & { state?: NavigationState }; getState: () => NavigationState; setState: (state: NavigationState) => void; @@ -24,22 +25,11 @@ export default function SceneView(props: Props) { const navigation = React.useMemo( () => ({ ...helpers, - setParams: (params: object) => { - performTransaction(() => { - const state = getState(); - - setState({ - ...state, - routes: state.routes.map(r => - r.key === route.key - ? { ...r, params: { ...r.params, ...params } } - : r - ), - }); - }); + setParams: (params: object, target?: TargetRoute) => { + helpers.setParams(params, target ? target : { key: route.key }); }, }), - [getState, helpers, performTransaction, route.key, setState] + [helpers, route.key] ); const getCurrentState = React.useCallback(() => { diff --git a/src/__tests__/BaseActions.test.tsx b/src/__tests__/BaseActions.test.tsx index 0bc99190..1d50a3f2 100644 --- a/src/__tests__/BaseActions.test.tsx +++ b/src/__tests__/BaseActions.test.tsx @@ -76,3 +76,73 @@ it('throws if NAVIGATE dispatched neither both key nor name', () => { 'While calling navigate you need to specify either name or key' ); }); + +it('throws if SET_PARAMS dispatched with both key and name', () => { + const TestNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder(MockRouter, props); + + return descriptors[state.routes[state.index].key].render(); + }; + + const FooScreen = (props: any) => { + React.useEffect(() => { + props.navigation.setParams({}, { key: '1', name: '2' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; + }; + + const onStateChange = jest.fn(); + + const element = ( + + + + + + ); + + expect(() => render(element).update(element)).toThrowError( + 'While calling setState with given second param you need to specify either name or key' + ); +}); + +it('throws if SET_PARAMS dispatched neither both key nor name', () => { + const TestNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder(MockRouter, props); + + return descriptors[state.routes[state.index].key].render(); + }; + + const FooScreen = (props: any) => { + React.useEffect(() => { + props.navigation.setParams({}, {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; + }; + + const onStateChange = jest.fn(); + + const element = ( + + + + + + ); + + expect(() => render(element).update(element)).toThrowError( + 'While calling setState with given second param you need to specify either name or key' + ); +}); diff --git a/src/__tests__/__fixtures__/MockRouter.tsx b/src/__tests__/__fixtures__/MockRouter.tsx index aa1576f6..69c1f77d 100644 --- a/src/__tests__/__fixtures__/MockRouter.tsx +++ b/src/__tests__/__fixtures__/MockRouter.tsx @@ -1,6 +1,9 @@ -import { Router } from '../../types'; +import { Router, CommonAction } from '../../types'; +import { BaseRouter } from '../../index'; -const MockRouter: Router<{ type: string }> & { key: number } = { +export type MockActions = CommonAction & { type: 'NOOP' | 'REVERSE' | 'UPDATE' }; + +const MockRouter: Router & { key: number } = { key: 0, getInitialState({ @@ -63,7 +66,7 @@ const MockRouter: Router<{ type: string }> & { key: number } = { return state; default: - return null; + return BaseRouter.getStateForAction(state, action); } }, diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 9e8cccc7..d594f2ba 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -412,6 +412,56 @@ it('updates route params with setParams', () => { }); }); +it('updates another route params with setParams', () => { + const TestNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder(MockRouter, props); + + return descriptors[state.routes[state.index].key].render(); + }; + + let setParams: (params: object, target: object) => void = () => undefined; + + const FooScreen = (props: any) => { + setParams = props.navigation.setParams; + + return null; + }; + + const onStateChange = jest.fn(); + + render( + + + + + + + ); + + act(() => setParams({ username: 'alice' }, { name: 'bar' })); + + expect(onStateChange).toBeCalledTimes(1); + expect(onStateChange).lastCalledWith({ + index: 0, + key: '0', + routeNames: ['foo', 'bar'], + routes: [{ key: 'foo', name: 'foo', params: undefined }, { key: 'bar', name: 'bar', params: { username: 'alice' } }], + }); + + act(() => setParams({ age: 25 }, { name: 'bar' })); + + expect(onStateChange).toBeCalledTimes(2); + expect(onStateChange).lastCalledWith({ + index: 0, + key: '0', + routeNames: ['foo', 'bar'], + routes: [ + { key: 'foo', name: 'foo', params: undefined }, + { key: 'bar', name: 'bar', params: { username: 'alice', age: 25 } }, + ], + }); +}); + it('handles change in route names', () => { const TestNavigator = (props: any): any => { useNavigationBuilder(MockRouter, props); diff --git a/src/__tests__/useOnAction.test.tsx b/src/__tests__/useOnAction.test.tsx index be91e842..d62faaf7 100644 --- a/src/__tests__/useOnAction.test.tsx +++ b/src/__tests__/useOnAction.test.tsx @@ -4,12 +4,12 @@ import { Router } from '../types'; import useNavigationBuilder from '../useNavigationBuilder'; import NavigationContainer from '../NavigationContainer'; import Screen from '../Screen'; -import MockRouter from './__fixtures__/MockRouter'; +import MockRouter, { MockActions } from './__fixtures__/MockRouter'; beforeEach(() => (MockRouter.key = 0)); it("lets parent handle the action if child didn't", () => { - const ParentRouter: Router<{ type: string }> = { + const ParentRouter: Router = { ...MockRouter, getStateForAction(state, action) { @@ -78,7 +78,7 @@ it("lets parent handle the action if child didn't", () => { }); it("lets children handle the action if parent didn't", () => { - const ParentRouter: Router<{ type: string }> = { + const ParentRouter: Router = { ...MockRouter, shouldActionPropagateToChildren() { @@ -86,7 +86,7 @@ it("lets children handle the action if parent didn't", () => { }, }; - const ChildRouter: Router<{ type: string }> = { + const ChildRouter: Router = { ...MockRouter, shouldActionChangeFocus() { diff --git a/src/index.tsx b/src/index.tsx index 706e6615..587e457a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,5 +2,6 @@ export { default as NavigationContainer } from './NavigationContainer'; export { default as createNavigator } from './createNavigator'; export { default as useNavigationBuilder } from './useNavigationBuilder'; +export { default as BaseRouter } from './BaseRouter'; export * from './types'; diff --git a/src/types.tsx b/src/types.tsx index 4e7b2a2f..ee45155a 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -135,7 +135,7 @@ export type Router = { /** * Action creators for the router. */ - actionCreators: ActionCreators; + actionCreators?: ActionCreators; }; export type ParamListBase = { [key: string]: object | undefined }; @@ -149,9 +149,7 @@ class PrivateValueStore { private __private_value_type?: T; } -export type NavigationHelpers< - ParamList extends ParamListBase = ParamListBase -> = { +export type NavigationProp = { /** * Dispatch an action or an update function to the router. * The update function will receive the current state, @@ -198,20 +196,19 @@ export type NavigationHelpers< * Go back to the previous route in history. */ goBack(): void; -} & PrivateValueStore; -export type NavigationProp< - ParamList extends ParamListBase, - RouteName extends keyof ParamList -> = NavigationHelpers & { /** * Update the param object for the route. * The new params will be shallow merged with the old one. * * @param params Params object for the current route. + * @routeName params Target route for setParam. */ - setParams(params: ParamList[RouteName]): void; -}; + setParams>( + params: ParamList[RouteName], + target: TargetRoute + ): void; +} & PrivateValueStore; export type RouteProp< ParamList extends ParamListBase, @@ -227,12 +224,12 @@ export type RouteProp< }); export type CompositeNavigationProp< - A extends NavigationHelpers, - B extends NavigationHelpers -> = Omit> & - NavigationHelpers< - (A extends NavigationHelpers ? T : never) & - (B extends NavigationHelpers ? U : never) + A extends NavigationProp, + B extends NavigationProp +> = Omit> & + NavigationProp< + (A extends NavigationProp ? T : never) & + (B extends NavigationProp ? U : never) >; export type Descriptor = { diff --git a/src/useDescriptors.tsx b/src/useDescriptors.tsx index 8ad5b81c..d10fedc2 100644 --- a/src/useDescriptors.tsx +++ b/src/useDescriptors.tsx @@ -3,7 +3,7 @@ import { Descriptor, PartialState, NavigationAction, - NavigationHelpers, + NavigationProp, NavigationState, ParamListBase, RouteConfig, @@ -16,7 +16,7 @@ import NavigationBuilderContext, { type Options = { state: NavigationState | PartialState; screens: { [key: string]: RouteConfig }; - navigation: NavigationHelpers; + navigation: NavigationProp; onAction: (action: NavigationAction, sourceNavigatorKey?: string) => boolean; getState: () => NavigationState; setState: (state: NavigationState) => void; diff --git a/src/useNavigationHelpers.tsx b/src/useNavigationHelpers.tsx index ff0afd69..b91d939c 100644 --- a/src/useNavigationHelpers.tsx +++ b/src/useNavigationHelpers.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import * as BaseActions from './BaseActions'; import NavigationBuilderContext from './NavigationBuilderContext'; import { - NavigationHelpers, + NavigationProp, NavigationAction, NavigationState, ActionCreators, @@ -13,7 +13,7 @@ type Options = { onAction: (action: NavigationAction, sourceNavigatorKey?: string) => boolean; getState: () => NavigationState; setState: (state: NavigationState) => void; - actionCreators: ActionCreators; + actionCreators?: ActionCreators; }; export default function useNavigationHelpers({ @@ -28,7 +28,7 @@ export default function useNavigationHelpers({ const { performTransaction } = React.useContext(NavigationStateContext); - return React.useMemo((): NavigationHelpers => { + return React.useMemo((): NavigationProp => { const dispatch = ( action: NavigationAction | ((state: NavigationState) => NavigationState) ) => {