From ea8655252da161d81dd0a630eb8c2f893ab88374 Mon Sep 17 00:00:00 2001 From: Michal Osadnik Date: Mon, 15 Jul 2019 00:53:27 +0100 Subject: [PATCH] feat: let child navigators handle actions from parent Co-authored-by: Satyajit Sahoo --- example/StackNavigator.tsx | 29 ++++++ example/TabNavigator.tsx | 24 +++++ src/NavigationBuilderContext.tsx | 16 ++- src/NavigationContainer.tsx | 1 + src/SceneView.tsx | 6 +- src/__tests__/index.test.tsx | 52 +++++++--- src/__tests__/useOnChildUpdate.test.tsx | 129 ++++++++++++++++++++++++ src/types.tsx | 28 +++++ src/useChildActionListeners.tsx | 28 +++++ src/useDescriptors.tsx | 21 +++- src/useNavigationBuilder.tsx | 23 +++++ src/useNavigationHelpers.tsx | 2 +- src/useOnAction.tsx | 65 ++++++++++-- src/useOnChildUpdate.tsx | 53 ++++++++++ 14 files changed, 448 insertions(+), 29 deletions(-) create mode 100644 src/__tests__/useOnChildUpdate.test.tsx create mode 100644 src/useChildActionListeners.tsx create mode 100644 src/useOnChildUpdate.tsx diff --git a/example/StackNavigator.tsx b/example/StackNavigator.tsx index 10b365a2..a1143875 100644 --- a/example/StackNavigator.tsx +++ b/example/StackNavigator.tsx @@ -211,6 +211,35 @@ 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'; + }, + + 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 6cde9e7d..9e326577 100644 --- a/example/TabNavigator.tsx +++ b/example/TabNavigator.tsx @@ -137,6 +137,30 @@ 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'; + }, + + shouldActionChangeFocus(action) { + return action.type === 'NAVIGATE'; + }, + actionCreators: { jumpTo(name: string, params?: object) { return { type: 'JUMP_TO', payload: { name, params } }; diff --git a/src/NavigationBuilderContext.tsx b/src/NavigationBuilderContext.tsx index d275b884..7a7e2cfc 100644 --- a/src/NavigationBuilderContext.tsx +++ b/src/NavigationBuilderContext.tsx @@ -1,9 +1,21 @@ import * as React from 'react'; -import { NavigationHelpers, NavigationAction } from './types'; +import { NavigationHelpers, NavigationAction, NavigationState } from './types'; + +export type ChildActionListener = ( + action: NavigationAction, + sourceRouteKey?: string +) => boolean; const NavigationBuilderContext = React.createContext<{ helpers?: NavigationHelpers; - onAction?: (action: NavigationAction) => boolean; + onAction?: (action: NavigationAction, sourceNavigatorKey?: string) => boolean; + addActionListener?: (listener: ChildActionListener) => void; + removeActionListener?: (listener: ChildActionListener) => void; + onChildUpdate?: ( + state: NavigationState, + focus: boolean, + key: string | undefined + ) => void; }>({}); export default NavigationBuilderContext; diff --git a/src/NavigationContainer.tsx b/src/NavigationContainer.tsx index 5e0cb1ba..5cb6d3cf 100644 --- a/src/NavigationContainer.tsx +++ b/src/NavigationContainer.tsx @@ -19,6 +19,7 @@ export const NavigationStateContext = React.createContext<{ state?: NavigationState | PartialState; getState: () => NavigationState | PartialState | undefined; setState: (state: NavigationState | undefined) => void; + key?: string; }>({ get getState(): any { throw new Error(MISSING_CONTEXT_ERROR); diff --git a/src/SceneView.tsx b/src/SceneView.tsx index d8c91ca8..2e8bb2d2 100644 --- a/src/SceneView.tsx +++ b/src/SceneView.tsx @@ -42,8 +42,9 @@ export default function SceneView(props: Props) { const getCurrentState = React.useCallback(() => { const state = getState(); + const currentRoute = state.routes.find(r => r.key === route.key); - return state.routes.find(r => r.key === route.key)!.state; + return currentRoute ? currentRoute.state : undefined; }, [getState, route.key]); const setCurrentState = React.useCallback( @@ -65,8 +66,9 @@ export default function SceneView(props: Props) { state: route.state, getState: getCurrentState, setState: setCurrentState, + key: route.key, }), - [getCurrentState, route.state, setCurrentState] + [getCurrentState, route.key, route.state, setCurrentState] ); return ( diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 1c1e2c62..428444ad 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -5,7 +5,9 @@ import NavigationContainer from '../NavigationContainer'; import useNavigationBuilder from '../useNavigationBuilder'; import { Router } from '../types'; -const MockRouter: Router<{ type: string }> = { +let key = 0; + +export const MockRouter: Router<{ type: string }> = { getInitialState({ routeNames, initialRouteName = routeNames[0], @@ -14,7 +16,7 @@ const MockRouter: Router<{ type: string }> = { const index = routeNames.indexOf(initialRouteName); return { - key: 'root', + key: String(key++), index, routeNames, routes: routeNames.map(name => ({ @@ -32,7 +34,7 @@ const MockRouter: Router<{ type: string }> = { state = { ...state, routeNames: state.routeNames || routeNames, - key: state.key || 'root', + key: state.key || String(key++), }; } @@ -52,9 +54,35 @@ const MockRouter: Router<{ type: string }> = { } }, + 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: {}, }; +beforeEach(() => (key = 0)); + it('initializes state for a navigator on navigation', () => { const TestNavigator = (props: any) => { const { navigation, descriptors } = useNavigationBuilder(MockRouter, props); @@ -100,7 +128,7 @@ it('initializes state for a navigator on navigation', () => { expect(onStateChange).toBeCalledTimes(1); expect(onStateChange).toBeCalledWith({ index: 0, - key: 'root', + key: '1', routeNames: ['foo', 'bar', 'baz'], routes: [ { key: 'foo', name: 'foo', params: { count: 10 } }, @@ -151,7 +179,7 @@ it('rehydrates state for a navigator on navigation', () => { expect(onStateChange).lastCalledWith({ index: 1, - key: 'root', + key: '0', routeNames: ['foo', 'bar'], routes: [{ key: 'foo', name: 'foo' }, { key: 'bar', name: 'bar' }], }); @@ -198,7 +226,7 @@ it('initializes state for nested navigator on navigation', () => { expect(onStateChange).toBeCalledTimes(1); expect(onStateChange).toBeCalledWith({ index: 2, - key: 'root', + key: '4', routeNames: ['foo', 'bar', 'baz'], routes: [ { key: 'foo', name: 'foo' }, @@ -208,7 +236,7 @@ it('initializes state for nested navigator on navigation', () => { name: 'baz', state: { index: 0, - key: 'root', + key: '3', routeNames: ['qux'], routes: [{ key: 'qux', name: 'qux' }], }, @@ -317,7 +345,7 @@ it('cleans up state when the navigator unmounts', () => { expect(onStateChange).toBeCalledTimes(1); expect(onStateChange).lastCalledWith({ index: 0, - key: 'root', + key: '1', routeNames: ['foo', 'bar'], routes: [{ key: 'foo', name: 'foo' }, { key: 'bar', name: 'bar' }], }); @@ -396,7 +424,7 @@ it("lets parent handle the action if child didn't", () => { expect(onStateChange).toBeCalledTimes(1); expect(onStateChange).lastCalledWith({ index: 2, - key: 'root', + key: '4', routeNames: ['foo', 'bar', 'baz'], routes: [ { key: 'baz', name: 'baz' }, @@ -446,7 +474,7 @@ it('allows arbitrary state updates by dispatching a function', () => { expect(onStateChange).toBeCalledTimes(1); expect(onStateChange).toBeCalledWith({ index: 1, - key: 'root', + key: '1', routeNames: ['foo', 'bar'], routes: [{ key: 'bar', name: 'bar' }, { key: 'foo', name: 'foo' }], }); @@ -485,7 +513,7 @@ it('updates route params with setParams', () => { expect(onStateChange).toBeCalledTimes(1); expect(onStateChange).lastCalledWith({ index: 0, - key: 'root', + key: '2', routeNames: ['foo', 'bar'], routes: [ { key: 'foo', name: 'foo', params: { username: 'alice' } }, @@ -498,7 +526,7 @@ it('updates route params with setParams', () => { expect(onStateChange).toBeCalledTimes(2); expect(onStateChange).lastCalledWith({ index: 0, - key: 'root', + key: '2', routeNames: ['foo', 'bar'], routes: [ { key: 'foo', name: 'foo', params: { username: 'alice', age: 25 } }, diff --git a/src/__tests__/useOnChildUpdate.test.tsx b/src/__tests__/useOnChildUpdate.test.tsx new file mode 100644 index 00000000..613b48fa --- /dev/null +++ b/src/__tests__/useOnChildUpdate.test.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import { render } from 'react-native-testing-library'; +import { Router } from '../types'; +import useNavigationBuilder from '../useNavigationBuilder'; +import NavigationContainer from '../NavigationContainer'; +import Screen from '../Screen'; +import { MockRouter } from './index.test'; + +it("lets children handle the action if parent didn't", () => { + const ParentRouter: Router<{ type: string }> = { + ...MockRouter, + + shouldActionPropagateToChildren() { + return true; + }, + }; + + const ChildRouter: Router<{ type: string }> = { + ...MockRouter, + + shouldActionChangeFocus() { + return true; + }, + + getStateForAction(state, action) { + if (action.type === 'REVERSE') { + return { + ...state, + routes: state.routes.slice().reverse(), + }; + } + + return MockRouter.getStateForAction(state, action); + }, + }; + + const ChildNavigator = (props: any) => { + const { navigation, descriptors } = useNavigationBuilder( + ChildRouter, + props + ); + + return descriptors[ + navigation.state.routes[navigation.state.index].key + ].render(); + }; + + const ParentNavigator = (props: any) => { + const { navigation, descriptors } = useNavigationBuilder( + ParentRouter, + props + ); + + return ( + + {navigation.state.routes.map(route => descriptors[route.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(); + + const initialState = { + index: 1, + routes: [ + { + key: 'baz', + name: 'baz', + state: { + index: 0, + key: '3', + routeNames: ['qux', 'lex'], + routes: [{ key: 'qux', name: 'qux' },{ key: 'lex', name: 'lex' }], + }, + }, + { key: 'bar', name: 'bar' }, + ], + }; + + render( + + + {() => null} + {TestScreen} + + {() => ( + + null} /> + null} /> + + )} + + + + ); + + expect(onStateChange).toBeCalledTimes(1); + expect(onStateChange).lastCalledWith({ + index: 0, + key: '2', + routeNames: ['foo', 'bar', 'baz'], + routes: [ + { + key: 'baz', + name: 'baz', + state: { + index: 0, + key: '3', + routeNames: ['qux', 'lex'], + routes: [{ key: 'lex', name: 'lex' }, { key: 'qux', name: 'qux' }], + }, + }, + { key: 'bar', name: 'bar' }, + ], + }); +}); diff --git a/src/types.tsx b/src/types.tsx index b090515f..69399805 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -87,6 +87,34 @@ export type Router = { action: Action ): NavigationState | null; + 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 + */ + shouldActionPropagateToChildren( + action: NavigationAction, + navigatorKey: string, + sourceNavigatorKey?: string + ): boolean; + + /** + * Whether the action should also change focus in parent navigator + */ + shouldActionChangeFocus( + action: NavigationAction, + navigatorKey: string, + sourceNavigatorKey?: string + ): boolean; + /** * Action creators for the router. */ diff --git a/src/useChildActionListeners.tsx b/src/useChildActionListeners.tsx new file mode 100644 index 00000000..bb386d64 --- /dev/null +++ b/src/useChildActionListeners.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { ChildActionListener } from './NavigationBuilderContext'; + +export default function useChildActionListeners() { + const { current: listeners } = React.useRef([]); + + const addActionListener = React.useCallback( + (listener: ChildActionListener) => { + listeners.push(listener); + }, + [listeners] + ); + + const removeActionListener = React.useCallback( + (listener: ChildActionListener) => { + const index = listeners.indexOf(listener); + + listeners.splice(index, 1); + }, + [listeners] + ); + + return { + listeners, + addActionListener, + removeActionListener, + }; +} diff --git a/src/useDescriptors.tsx b/src/useDescriptors.tsx index df4a2447..caa8b69f 100644 --- a/src/useDescriptors.tsx +++ b/src/useDescriptors.tsx @@ -9,15 +9,24 @@ import { ScreenProps, } from './types'; import SceneView from './SceneView'; -import NavigationBuilderContext from './NavigationBuilderContext'; +import NavigationBuilderContext, { + ChildActionListener, +} from './NavigationBuilderContext'; type Options = { state: NavigationState | PartialState; screens: { [key: string]: ScreenProps }; helpers: NavigationHelpers; - onAction: (action: NavigationAction) => boolean; + onAction: (action: NavigationAction, sourceNavigatorKey?: string) => boolean; getState: () => NavigationState; setState: (state: NavigationState) => void; + addActionListener: (listener: ChildActionListener) => void; + removeActionListener: (listener: ChildActionListener) => void; + onChildUpdate: ( + state: NavigationState, + focus: boolean, + key: string | undefined + ) => void; }; const EMPTY_OPTIONS = Object.freeze({}); @@ -29,13 +38,19 @@ export default function useDescriptors({ onAction, getState, setState, + addActionListener, + removeActionListener, + onChildUpdate, }: Options) { const context = React.useMemo( () => ({ helpers, onAction, + addActionListener, + removeActionListener, + onChildUpdate, }), - [helpers, onAction] + [helpers, onAction, onChildUpdate, addActionListener, removeActionListener] ); return state.routes.reduce( diff --git a/src/useNavigationBuilder.tsx b/src/useNavigationBuilder.tsx index 03f8c76f..223c9c13 100644 --- a/src/useNavigationBuilder.tsx +++ b/src/useNavigationBuilder.tsx @@ -6,6 +6,8 @@ import useDescriptors from './useDescriptors'; import useNavigationHelpers from './useNavigationHelpers'; import useOnAction from './useOnAction'; import { Router, NavigationState, ScreenProps } from './types'; +import useOnChildUpdate from './useOnChildUpdate'; +import useChildActionListeners from './useChildActionListeners'; type Options = { initialRouteName?: string; @@ -66,6 +68,7 @@ export default function useNavigationBuilder( }), getState: getCurrentState, setState, + key, } = React.useContext(NavigationStateContext); React.useEffect(() => { @@ -92,10 +95,27 @@ export default function useNavigationBuilder( [getCurrentState, router.getRehydratedState, router.getInitialState] ); + const { + listeners: actionListeners, + addActionListener, + removeActionListener, + } = useChildActionListeners(); + const onAction = useOnAction({ + router, getState, setState, + key, getStateForAction: router.getStateForAction, + actionListeners, + }); + + const onChildUpdate = useOnChildUpdate({ + router, + onAction, + key, + getState, + setState, }); const helpers = useNavigationHelpers({ @@ -120,6 +140,9 @@ export default function useNavigationBuilder( onAction, getState, setState, + onChildUpdate, + addActionListener, + removeActionListener, }); return { diff --git a/src/useNavigationHelpers.tsx b/src/useNavigationHelpers.tsx index d936839d..48fd8bac 100644 --- a/src/useNavigationHelpers.tsx +++ b/src/useNavigationHelpers.tsx @@ -9,7 +9,7 @@ import { } from './types'; type Options = { - onAction: (action: NavigationAction) => boolean; + onAction: (action: NavigationAction, sourceNavigatorKey?: string) => boolean; getState: () => NavigationState; setState: (state: NavigationState) => void; actionCreators: ActionCreators; diff --git a/src/useOnAction.tsx b/src/useOnAction.tsx index 4c4735df..c5ee8099 100644 --- a/src/useOnAction.tsx +++ b/src/useOnAction.tsx @@ -1,32 +1,54 @@ import * as React from 'react'; -import NavigationBuilderContext from './NavigationBuilderContext'; -import { NavigationAction, NavigationState } from './types'; +import NavigationBuilderContext, { + ChildActionListener, +} from './NavigationBuilderContext'; +import { NavigationAction, NavigationState, Router } from './types'; type Options = { + router: Router; getState: () => NavigationState; + key?: string; setState: (state: NavigationState) => void; getStateForAction: ( state: NavigationState, action: NavigationAction ) => NavigationState | null; + actionListeners: ChildActionListener[]; }; export default function useOnAction({ + router, getState, setState, + key, getStateForAction, + actionListeners, }: Options) { - const { onAction: handleActionParent } = React.useContext( - NavigationBuilderContext - ); + const { + onAction: handleActionParent, + onChildUpdate: handleChildUpdateParent, + } = React.useContext(NavigationBuilderContext); return React.useCallback( - (action: NavigationAction) => { + (action: NavigationAction, sourceNavigatorKey?: string) => { const state = getState(); + + if (sourceNavigatorKey === state.key) { + return false; + } + const result = getStateForAction(state, action); if (result !== null) { - if (state !== result) { + if (handleChildUpdateParent) { + const shouldFocus = router.shouldActionChangeFocus( + action, + state.key, + sourceNavigatorKey + ); + + handleChildUpdateParent(result, shouldFocus, key); + } else if (state !== result) { setState(result); } @@ -35,13 +57,38 @@ export default function useOnAction({ if (handleActionParent !== undefined) { // Bubble action to the parent if the current navigator didn't handle it - if (handleActionParent(action)) { + if (handleActionParent(action, state.key)) { return true; } } + if ( + router.shouldActionPropagateToChildren( + action, + state.key, + sourceNavigatorKey + ) + ) { + for (let i = actionListeners.length - 1; i >= 0; i--) { + const listener = actionListeners[i]; + + if (listener(action, state.key)) { + return true; + } + } + } + return false; }, - [getState, handleActionParent, getStateForAction, setState] + [ + getState, + getStateForAction, + handleActionParent, + router, + handleChildUpdateParent, + key, + setState, + actionListeners, + ] ); } diff --git a/src/useOnChildUpdate.tsx b/src/useOnChildUpdate.tsx new file mode 100644 index 00000000..2908cef9 --- /dev/null +++ b/src/useOnChildUpdate.tsx @@ -0,0 +1,53 @@ +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; +}