diff --git a/example/StackNavigator.tsx b/example/StackNavigator.tsx index ebc4d973..befd53d1 100644 --- a/example/StackNavigator.tsx +++ b/example/StackNavigator.tsx @@ -96,6 +96,20 @@ const StackRouter: Router = { }; }, + getStateForRouteFocus(state, key) { + const index = state.routes.findIndex(r => r.key === key); + + if (index === -1 || index === state.index) { + return state; + } + + return { + ...state, + index, + routes: state.routes.slice(0, index + 1), + }; + }, + getStateForAction(state, action) { switch (action.type) { case 'PUSH': @@ -219,27 +233,6 @@ const StackRouter: Router = { } }, - getStateForChildUpdate(state, { update, focus, key }) { - const index = state.routes.findIndex(r => r.key === key); - - if (index === -1) { - return state; - } - - return { - ...state, - index: focus ? index : state.index, - routes: focus - ? [ - ...state.routes.slice(0, index), - { ...state.routes[index], state: update }, - ] - : state.routes.map((route, i) => - i === index ? { ...route, state: update } : route - ), - }; - }, - shouldActionPropagateToChildren(action) { return action.type === 'NAVIGATE'; }, diff --git a/example/TabNavigator.tsx b/example/TabNavigator.tsx index 1e80678a..ca2ea7ed 100644 --- a/example/TabNavigator.tsx +++ b/example/TabNavigator.tsx @@ -87,6 +87,16 @@ const TabRouter: Router = { }; }, + getStateForRouteFocus(state, key) { + const index = state.routes.findIndex(r => r.key === key); + + if (index === -1) { + return state; + } + + return { ...state, index }; + }, + getStateForAction(state, action) { switch (action.type) { case 'JUMP_TO': @@ -152,22 +162,6 @@ const TabRouter: Router = { } }, - getStateForChildUpdate(state, { update, focus, key }) { - const index = state.routes.findIndex(r => r.key === key); - - if (index === -1) { - return state; - } - - return { - ...state, - index: focus ? index : state.index, - routes: state.routes.map((route, i) => - i === index ? { ...route, state: update } : route - ), - }; - }, - shouldActionPropagateToChildren(action) { return action.type === 'NAVIGATE'; }, diff --git a/src/NavigationBuilderContext.tsx b/src/NavigationBuilderContext.tsx index 5e4a0450..4b7bc4bf 100644 --- a/src/NavigationBuilderContext.tsx +++ b/src/NavigationBuilderContext.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { NavigationHelpers, NavigationAction, NavigationState } from './types'; +import { NavigationHelpers, NavigationAction } from './types'; export type ChildActionListener = ( action: NavigationAction, @@ -11,11 +11,7 @@ const NavigationBuilderContext = React.createContext<{ onAction?: (action: NavigationAction, sourceNavigatorKey?: string) => boolean; addActionListener?: (listener: ChildActionListener) => void; removeActionListener?: (listener: ChildActionListener) => void; - onChildUpdate?: ( - state: NavigationState, - focus: boolean, - key: string | undefined - ) => void; + onRouteFocus?: (key: string) => void; }>({}); export default NavigationBuilderContext; diff --git a/src/__tests__/__fixtures__/MockRouter.tsx b/src/__tests__/__fixtures__/MockRouter.tsx new file mode 100644 index 00000000..aa1576f6 --- /dev/null +++ b/src/__tests__/__fixtures__/MockRouter.tsx @@ -0,0 +1,81 @@ +import { Router } from '../../types'; + +const MockRouter: Router<{ type: string }> & { key: number } = { + key: 0, + + getInitialState({ + routeNames, + initialRouteName = routeNames[0], + initialParamsList, + }) { + const index = routeNames.indexOf(initialRouteName); + + return { + key: String(MockRouter.key++), + index, + routeNames, + routes: routeNames.map(name => ({ + name, + key: name, + params: initialParamsList[name], + })), + }; + }, + + getRehydratedState({ routeNames, partialState }) { + let state = partialState; + + if (state.routeNames === undefined || state.key === undefined) { + state = { + ...state, + routeNames, + key: String(MockRouter.key++), + }; + } + + 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); + + if (index === -1 || index === state.index) { + return state; + } + + return { ...state, index }; + }, + + getStateForAction(state, action) { + switch (action.type) { + case 'UPDATE': + return { ...state }; + + case 'NOOP': + return state; + + default: + return null; + } + }, + + shouldActionPropagateToChildren() { + return false; + }, + + shouldActionChangeFocus() { + return false; + }, + + actionCreators: {}, +}; + +export default MockRouter; diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 395ccda6..9e8cccc7 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -3,91 +3,7 @@ import { render, act } from 'react-native-testing-library'; import Screen from '../Screen'; import NavigationContainer from '../NavigationContainer'; import useNavigationBuilder from '../useNavigationBuilder'; -import { Router } from '../types'; - -export const MockRouter: Router<{ type: string }> & { key: number } = { - key: 0, - - getInitialState({ - routeNames, - initialRouteName = routeNames[0], - initialParamsList, - }) { - const index = routeNames.indexOf(initialRouteName); - - return { - key: String(MockRouter.key++), - index, - routeNames, - routes: routeNames.map(name => ({ - name, - key: name, - params: initialParamsList[name], - })), - }; - }, - - getRehydratedState({ routeNames, partialState }) { - let state = partialState; - - if (state.routeNames === undefined || state.key === undefined) { - state = { - ...state, - routeNames, - key: String(MockRouter.key++), - }; - } - - return state; - }, - - getStateForRouteNamesChange(state, { routeNames }) { - return { - ...state, - routeNames, - routes: state.routes.filter(route => routeNames.includes(route.name)), - }; - }, - - getStateForAction(state, action) { - switch (action.type) { - case 'UPDATE': - return { ...state }; - - case 'NOOP': - return state; - - default: - return null; - } - }, - - getStateForChildUpdate(state, { update, focus, key }) { - const index = state.routes.findIndex(r => r.key === key); - - if (index === -1) { - return state; - } - - return { - ...state, - index: focus ? index : state.index, - routes: state.routes.map((route, i) => - i === index ? { ...route, state: update } : route - ), - }; - }, - - shouldActionPropagateToChildren() { - return false; - }, - - shouldActionChangeFocus() { - return false; - }, - - actionCreators: {}, -}; +import MockRouter from './__fixtures__/MockRouter'; beforeEach(() => (MockRouter.key = 0)); @@ -399,75 +315,6 @@ it('cleans up state when the navigator unmounts', () => { expect(onStateChange).lastCalledWith(undefined); }); -it("lets parent handle the action if child didn't", () => { - const ParentRouter: Router<{ type: string }> = { - ...MockRouter, - - getStateForAction(state, action) { - if (action.type === 'REVERSE') { - return { - ...state, - routes: state.routes.slice().reverse(), - }; - } - - return MockRouter.getStateForAction(state, action); - }, - }; - - const ParentNavigator = (props: any) => { - const { state, descriptors } = useNavigationBuilder(ParentRouter, props); - - return descriptors[state.routes[state.index].key].render(); - }; - - const ChildNavigator = (props: any) => { - const { state, descriptors } = useNavigationBuilder(MockRouter, props); - - return descriptors[state.routes[state.index].key].render(); - }; - - const TestScreen = (props: any) => { - React.useEffect(() => { - props.navigation.dispatch({ type: 'REVERSE' }); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return null; - }; - - const onStateChange = jest.fn(); - - render( - - - {() => null} - {() => null} - - {() => ( - - - - )} - - - - ); - - expect(onStateChange).toBeCalledTimes(1); - expect(onStateChange).lastCalledWith({ - index: 2, - key: '0', - routeNames: ['foo', 'bar', 'baz'], - routes: [ - { key: 'baz', name: 'baz' }, - { key: 'bar', name: 'bar' }, - { key: 'foo', name: 'foo' }, - ], - }); -}); - it('allows arbitrary state updates by dispatching a function', () => { const TestNavigator = (props: any) => { const { state, descriptors } = useNavigationBuilder(MockRouter, props); diff --git a/src/__tests__/useOnChildUpdate.test.tsx b/src/__tests__/useOnAction.test.tsx similarity index 62% rename from src/__tests__/useOnChildUpdate.test.tsx rename to src/__tests__/useOnAction.test.tsx index 080ae6b8..be91e842 100644 --- a/src/__tests__/useOnChildUpdate.test.tsx +++ b/src/__tests__/useOnAction.test.tsx @@ -4,10 +4,79 @@ import { Router } from '../types'; import useNavigationBuilder from '../useNavigationBuilder'; import NavigationContainer from '../NavigationContainer'; import Screen from '../Screen'; -import { MockRouter } from './index.test'; +import MockRouter from './__fixtures__/MockRouter'; beforeEach(() => (MockRouter.key = 0)); +it("lets parent handle the action if child didn't", () => { + const ParentRouter: Router<{ type: string }> = { + ...MockRouter, + + getStateForAction(state, action) { + if (action.type === 'REVERSE') { + return { + ...state, + routes: state.routes.slice().reverse(), + }; + } + + return MockRouter.getStateForAction(state, action); + }, + }; + + const ParentNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder(ParentRouter, props); + + return descriptors[state.routes[state.index].key].render(); + }; + + const ChildNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder(MockRouter, props); + + return descriptors[state.routes[state.index].key].render(); + }; + + const TestScreen = (props: any) => { + React.useEffect(() => { + props.navigation.dispatch({ type: 'REVERSE' }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; + }; + + const onStateChange = jest.fn(); + + render( + + + {() => null} + {() => null} + + {() => ( + + + + )} + + + + ); + + expect(onStateChange).toBeCalledTimes(1); + expect(onStateChange).lastCalledWith({ + index: 2, + key: '0', + routeNames: ['foo', 'bar', 'baz'], + routes: [ + { key: 'baz', name: 'baz' }, + { key: 'bar', name: 'bar' }, + { key: 'foo', name: 'foo' }, + ], + }); +}); + it("lets children handle the action if parent didn't", () => { const ParentRouter: Router<{ type: string }> = { ...MockRouter, diff --git a/src/types.tsx b/src/types.tsx index b03d1aed..26c31af7 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -92,6 +92,14 @@ export type Router = { } ): NavigationState; + /** + * Take the current state and key of a route, and return a new state with the route focused + * + * @param state State object to apply the action on. + * @param key Key of the route to focus. + */ + getStateForRouteFocus(state: NavigationState, key: string): NavigationState; + /** * Take the current state and action, and return a new state. * If the action cannot be handled, return `null`. @@ -104,23 +112,6 @@ export type Router = { action: Action ): NavigationState | null; - /** - * Update state for a child navigator and focus it - * - * @param state State object to apply the action on. - * @param options.update Updated navigation state for the child navigator. - * @param options.focus Whether to focus the new child. - * @param options.key Route key of the child to update. - */ - getStateForChildUpdate( - state: NavigationState, - payload: { - update: NavigationState; - focus: boolean; - key: string | undefined; - } - ): NavigationState; - /** * Whether the action bubbles to other navigators * When an action isn't handled by current navigator, it can be passed to nested navigators diff --git a/src/useDescriptors.tsx b/src/useDescriptors.tsx index 48114e8c..8ad5b81c 100644 --- a/src/useDescriptors.tsx +++ b/src/useDescriptors.tsx @@ -22,11 +22,7 @@ type Options = { setState: (state: NavigationState) => void; addActionListener: (listener: ChildActionListener) => void; removeActionListener: (listener: ChildActionListener) => void; - onChildUpdate: ( - state: NavigationState, - focus: boolean, - key: string | undefined - ) => void; + onRouteFocus: (key: string) => void; }; const EMPTY_OPTIONS = Object.freeze({}); @@ -40,7 +36,7 @@ export default function useDescriptors({ setState, addActionListener, removeActionListener, - onChildUpdate, + onRouteFocus, }: Options) { const context = React.useMemo( () => ({ @@ -48,12 +44,12 @@ export default function useDescriptors({ onAction, addActionListener, removeActionListener, - onChildUpdate, + onRouteFocus, }), [ navigation, onAction, - onChildUpdate, + onRouteFocus, addActionListener, removeActionListener, ] diff --git a/src/useNavigationBuilder.tsx b/src/useNavigationBuilder.tsx index 1ae60faa..2bbacb0e 100644 --- a/src/useNavigationBuilder.tsx +++ b/src/useNavigationBuilder.tsx @@ -6,7 +6,7 @@ import useDescriptors from './useDescriptors'; import useNavigationHelpers from './useNavigationHelpers'; import useOnAction from './useOnAction'; import { Router, NavigationState, RouteConfig } from './types'; -import useOnChildUpdate from './useOnChildUpdate'; +import useOnRouteFocus from './useOnRouteFocus'; import useChildActionListeners from './useChildActionListeners'; type Options = { @@ -138,11 +138,10 @@ export default function useNavigationBuilder( getState, setState, key, - getStateForAction: router.getStateForAction, - actionListeners, + listeners: actionListeners, }); - const onChildUpdate = useOnChildUpdate({ + const onRouteFocus = useOnRouteFocus({ router, onAction, key, @@ -164,7 +163,7 @@ export default function useNavigationBuilder( onAction, getState, setState, - onChildUpdate, + onRouteFocus, addActionListener, removeActionListener, }); diff --git a/src/useOnAction.tsx b/src/useOnAction.tsx index 8470fa6f..8dc4eb8b 100644 --- a/src/useOnAction.tsx +++ b/src/useOnAction.tsx @@ -5,15 +5,11 @@ import NavigationBuilderContext, { import { NavigationAction, NavigationState, Router } from './types'; type Options = { - router: Router; - getState: () => NavigationState; + router: Router; key?: string; + getState: () => NavigationState; setState: (state: NavigationState) => void; - getStateForAction: ( - state: NavigationState, - action: NavigationAction - ) => NavigationState | null; - actionListeners: ChildActionListener[]; + listeners: ChildActionListener[]; }; export default function useOnAction({ @@ -21,12 +17,11 @@ export default function useOnAction({ getState, setState, key, - getStateForAction, - actionListeners, + listeners, }: Options) { const { - onAction: handleActionParent, - onChildUpdate: handleChildUpdateParent, + onAction: onActionParent, + onRouteFocus: onRouteFocusParent, } = React.useContext(NavigationBuilderContext); return React.useCallback( @@ -37,30 +32,34 @@ export default function useOnAction({ return false; } - const result = getStateForAction(state, action); + const result = router.getStateForAction(state, action); if (result !== null) { - if (handleChildUpdateParent) { + if (state !== result) { + setState(result); + } + + if (onRouteFocusParent !== undefined) { const shouldFocus = router.shouldActionChangeFocus(action); - handleChildUpdateParent(result, shouldFocus, key); - } else if (state !== result) { - setState(result); + if (shouldFocus && key !== undefined) { + onRouteFocusParent(key); + } } return true; } - if (handleActionParent !== undefined) { + if (onActionParent !== undefined) { // Bubble action to the parent if the current navigator didn't handle it - if (handleActionParent(action, state.key)) { + if (onActionParent(action, state.key)) { return true; } } if (router.shouldActionPropagateToChildren(action)) { - for (let i = actionListeners.length - 1; i >= 0; i--) { - const listener = actionListeners[i]; + for (let i = listeners.length - 1; i >= 0; i--) { + const listener = listeners[i]; if (listener(action, state.key)) { return true; @@ -72,13 +71,12 @@ export default function useOnAction({ }, [ getState, - getStateForAction, - handleActionParent, router, - handleChildUpdateParent, - key, + onActionParent, + onRouteFocusParent, setState, - actionListeners, + key, + listeners, ] ); } diff --git a/src/useOnChildUpdate.tsx b/src/useOnChildUpdate.tsx deleted file mode 100644 index 2908cef9..00000000 --- a/src/useOnChildUpdate.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from 'react'; -import { NavigationAction, NavigationState, Router } from './types'; -import NavigationBuilderContext from './NavigationBuilderContext'; - -type Options = { - router: Router; - onAction: (action: NavigationAction, sourceNavigatorKey?: string) => boolean; - getState: () => NavigationState; - setState: (state: NavigationState) => void; - key?: string; -}; - -export default function useOnChildUpdate({ - router, - onAction, - getState, - key: sourceNavigatorKey, - setState, -}: Options) { - const { - onChildUpdate: parentOnChildUpdate, - addActionListener: parentAddActionListener, - removeActionListener: parentRemoveActionListener, - } = React.useContext(NavigationBuilderContext); - - React.useEffect(() => { - parentAddActionListener && parentAddActionListener(onAction); - - return () => { - parentRemoveActionListener && parentRemoveActionListener(onAction); - }; - }, [onAction, parentAddActionListener, parentRemoveActionListener]); - - const onChildUpdate = React.useCallback( - (update: NavigationState, focus: boolean, key: string | undefined) => { - const state = getState(); - const result = router.getStateForChildUpdate(state, { - update, - focus, - key, - }); - - if (parentOnChildUpdate !== undefined) { - parentOnChildUpdate(result, focus, sourceNavigatorKey); - } else { - setState(result); - } - }, - [getState, parentOnChildUpdate, sourceNavigatorKey, router, setState] - ); - - return onChildUpdate; -} diff --git a/src/useOnRouteFocus.tsx b/src/useOnRouteFocus.tsx new file mode 100644 index 00000000..f4564d3b --- /dev/null +++ b/src/useOnRouteFocus.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { NavigationAction, NavigationState, Router } from './types'; +import NavigationBuilderContext from './NavigationBuilderContext'; + +type Options = { + router: Router; + onAction: (action: NavigationAction, sourceNavigatorKey?: string) => boolean; + getState: () => NavigationState; + setState: (state: NavigationState) => void; + key?: string; +}; + +export default function useOnRouteFocus({ + router, + onAction, + getState, + key: sourceNavigatorKey, + setState, +}: Options) { + const { + onRouteFocus: onRouteFocusParent, + addActionListener: addActionListenerParent, + removeActionListener: removeActionListenerParent, + } = React.useContext(NavigationBuilderContext); + + React.useEffect(() => { + addActionListenerParent && addActionListenerParent(onAction); + + return () => { + removeActionListenerParent && removeActionListenerParent(onAction); + }; + }, [addActionListenerParent, onAction, removeActionListenerParent]); + + return React.useCallback( + (key: string) => { + const state = getState(); + const result = router.getStateForRouteFocus(state, key); + + if (result !== state) { + setState(result); + } + + if ( + onRouteFocusParent !== undefined && + sourceNavigatorKey !== undefined + ) { + onRouteFocusParent(sourceNavigatorKey); + } + }, + [getState, onRouteFocusParent, router, setState, sourceNavigatorKey] + ); +}