feat: let child navigators handle actions from parent

Co-authored-by: Satyajit Sahoo <satyajit.happy@gmail.com>
This commit is contained in:
Michal Osadnik
2019-07-15 00:53:27 +01:00
committed by satyajit.happy
parent f383d131d9
commit ea8655252d
14 changed files with 448 additions and 29 deletions

View File

@@ -211,6 +211,35 @@ const StackRouter: Router<CommonAction | Action> = {
}
},
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 } };

View File

@@ -137,6 +137,30 @@ const TabRouter: Router<Action | CommonAction> = {
}
},
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 } };

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 (

View File

@@ -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 } },

View File

@@ -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 (
<React.Fragment>
{navigation.state.routes.map(route => descriptors[route.key].render())}
</React.Fragment>
);
};
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(
<NavigationContainer
initialState={initialState}
onStateChange={onStateChange}
>
<ParentNavigator>
<Screen name="foo">{() => null}</Screen>
<Screen name="bar">{TestScreen}</Screen>
<Screen name="baz">
{() => (
<ChildNavigator>
<Screen name="qux" component={() => null} />
<Screen name="lex" component={() => null} />
</ChildNavigator>
)}
</Screen>
</ParentNavigator>
</NavigationContainer>
);
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' },
],
});
});

View File

@@ -87,6 +87,34 @@ export type Router<Action extends NavigationAction = CommonAction> = {
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.
*/

View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { ChildActionListener } from './NavigationBuilderContext';
export default function useChildActionListeners() {
const { current: listeners } = React.useRef<ChildActionListener[]>([]);
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,
};
}

View File

@@ -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<ParamListBase, string> };
helpers: NavigationHelpers<ParamListBase>;
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(

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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,
]
);
}

53
src/useOnChildUpdate.tsx Normal file
View File

@@ -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;
}