refactor: drop getStateForChildUpdate in favor of getStateForRouteFocus (#15)

This commit is contained in:
Satyajit Sahoo
2019-07-20 14:05:30 +02:00
committed by Michał Osadnik
parent 3d8ba13135
commit 44b2ace9ee
12 changed files with 269 additions and 306 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(
<NavigationContainer onStateChange={onStateChange}>
<ParentNavigator initialRouteName="baz">
<Screen name="foo">{() => null}</Screen>
<Screen name="bar">{() => null}</Screen>
<Screen name="baz">
{() => (
<ChildNavigator>
<Screen name="qux" component={TestScreen} />
</ChildNavigator>
)}
</Screen>
</ParentNavigator>
</NavigationContainer>
);
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);

View File

@@ -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(
<NavigationContainer onStateChange={onStateChange}>
<ParentNavigator initialRouteName="baz">
<Screen name="foo">{() => null}</Screen>
<Screen name="bar">{() => null}</Screen>
<Screen name="baz">
{() => (
<ChildNavigator>
<Screen name="qux" component={TestScreen} />
</ChildNavigator>
)}
</Screen>
</ParentNavigator>
</NavigationContainer>
);
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,

View File

@@ -92,6 +92,14 @@ export type Router<Action extends NavigationAction = CommonAction> = {
}
): 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 extends NavigationAction = CommonAction> = {
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

View File

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

View File

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

View File

@@ -5,15 +5,11 @@ import NavigationBuilderContext, {
import { NavigationAction, NavigationState, Router } from './types';
type Options = {
router: Router;
getState: () => NavigationState;
router: Router<NavigationAction>;
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,
]
);
}

View File

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

52
src/useOnRouteFocus.tsx Normal file
View File

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