refactor: add transaction support (#12)

This commit is contained in:
Michał Osadnik
2019-07-18 23:16:49 +01:00
committed by Michal Osadnik
parent 38aa8e447b
commit cb89debb94
5 changed files with 127 additions and 23 deletions

View File

@@ -18,8 +18,9 @@ const MISSING_CONTEXT_ERROR =
export const NavigationStateContext = React.createContext<{ export const NavigationStateContext = React.createContext<{
state?: NavigationState | PartialState; state?: NavigationState | PartialState;
getState: () => NavigationState | PartialState | undefined; getState: () => NavigationState | PartialState | undefined;
setState: (state: NavigationState | undefined) => void; setState: (state: NavigationState | undefined, dangerously?: boolean) => void;
key?: string; key?: string;
performTransaction: (action: () => void) => void;
}>({ }>({
get getState(): any { get getState(): any {
throw new Error(MISSING_CONTEXT_ERROR); throw new Error(MISSING_CONTEXT_ERROR);
@@ -27,6 +28,9 @@ export const NavigationStateContext = React.createContext<{
get setState(): any { get setState(): any {
throw new Error(MISSING_CONTEXT_ERROR); throw new Error(MISSING_CONTEXT_ERROR);
}, },
get performTransaction(): any {
throw new Error(MISSING_CONTEXT_ERROR);
},
}); });
export default class NavigationContainer extends React.Component<Props, State> { export default class NavigationContainer extends React.Component<Props, State> {
@@ -42,10 +46,39 @@ export default class NavigationContainer extends React.Component<Props, State> {
} }
} }
private getNavigationState = () => this.state.navigationState; private navigationState:
| NavigationState
| PartialState
| undefined
| null = null;
private setNavigationState = (navigationState: NavigationState | undefined) => private performTransaction = (action: () => void) => {
this.setState({ navigationState }); this.setState(
state => {
this.navigationState = state.navigationState;
action();
return { navigationState: this.navigationState };
},
() => (this.navigationState = null)
);
};
private getNavigationState = () =>
this.navigationState || this.state.navigationState;
private setNavigationState = (
navigationState: NavigationState | undefined,
dangerously = false
) => {
if (this.navigationState === null && !dangerously) {
throw new Error('setState need to be wrapped in a performTransaction');
}
if (dangerously) {
this.setState({ navigationState });
} else {
this.navigationState = navigationState;
}
};
render() { render() {
return ( return (
@@ -54,6 +87,7 @@ export default class NavigationContainer extends React.Component<Props, State> {
state: this.state.navigationState, state: this.state.navigationState,
getState: this.getNavigationState, getState: this.getNavigationState,
setState: this.setNavigationState, setState: this.setNavigationState,
performTransaction: this.performTransaction,
}} }}
> >
<EnsureSingleNavigator>{this.props.children}</EnsureSingleNavigator> <EnsureSingleNavigator>{this.props.children}</EnsureSingleNavigator>

View File

@@ -19,24 +19,27 @@ type Props = {
export default function SceneView(props: Props) { export default function SceneView(props: Props) {
const { screen, route, navigation: helpers, getState, setState } = props; const { screen, route, navigation: helpers, getState, setState } = props;
const { performTransaction } = React.useContext(NavigationStateContext);
const navigation = React.useMemo( const navigation = React.useMemo(
() => ({ () => ({
...helpers, ...helpers,
setParams: (params: object) => { setParams: (params: object) => {
const state = getState(); performTransaction(() => {
const state = getState();
setState({ setState({
...state, ...state,
routes: state.routes.map(r => routes: state.routes.map(r =>
r.key === route.key r.key === route.key
? { ...r, params: { ...r.params, ...params } } ? { ...r, params: { ...r.params, ...params } }
: r : r
), ),
});
}); });
}, },
}), }),
[getState, helpers, route.key, setState] [getState, helpers, performTransaction, route.key, setState]
); );
const getCurrentState = React.useCallback(() => { const getCurrentState = React.useCallback(() => {
@@ -65,9 +68,16 @@ export default function SceneView(props: Props) {
state: route.state, state: route.state,
getState: getCurrentState, getState: getCurrentState,
setState: setCurrentState, setState: setCurrentState,
performTransaction,
key: route.key, key: route.key,
}), }),
[getCurrentState, route.key, route.state, setCurrentState] [
getCurrentState,
performTransaction,
route.key,
route.state,
setCurrentState,
]
); );
return ( return (

View File

@@ -1,6 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import { render } from 'react-native-testing-library'; import { render } from 'react-native-testing-library';
import { NavigationStateContext } from '../NavigationContainer'; import NavigationContainer, {
NavigationStateContext,
} from '../NavigationContainer';
it('throws when getState is accessed without a container', () => { it('throws when getState is accessed without a container', () => {
expect.assertions(1); expect.assertions(1);
@@ -39,3 +41,46 @@ it('throws when setState is accessed without a container', () => {
"We couldn't find a navigation context. Have you wrapped your app with 'NavigationContainer'?" "We couldn't find a navigation context. Have you wrapped your app with 'NavigationContainer'?"
); );
}); });
it('throws when performTransaction is accessed without a container', () => {
expect.assertions(1);
const Test = () => {
const { performTransaction } = React.useContext(NavigationStateContext);
// eslint-disable-next-line babel/no-unused-expressions
performTransaction;
return null;
};
const element = <Test />;
expect(() => render(element).update(element)).toThrowError(
"We couldn't find a navigation context. Have you wrapped your app with 'NavigationContainer'?"
);
});
it('throws when setState is called outside performTransaction', () => {
expect.assertions(1);
const Test = () => {
const { setState } = React.useContext(NavigationStateContext);
React.useEffect(() => {
setState(undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
};
const element = (
<NavigationContainer>
<Test />
</NavigationContainer>
);
expect(() => render(element).update(element)).toThrowError(
'setState need to be wrapped in a performTransaction'
);
});

View File

@@ -74,6 +74,7 @@ export default function useNavigationBuilder(
getState: getCurrentState, getState: getCurrentState,
setState, setState,
key, key,
performTransaction,
} = React.useContext(NavigationStateContext); } = React.useContext(NavigationStateContext);
let state = router.getRehydratedState({ let state = router.getRehydratedState({
@@ -93,7 +94,7 @@ export default function useNavigationBuilder(
// If the state needs to be updated, we'll schedule an update with React // If the state needs to be updated, we'll schedule an update with React
// setState in render seems hacky, but that's how React docs implement getDerivedPropsFromState // setState in render seems hacky, but that's how React docs implement getDerivedPropsFromState
// https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops // https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops
setState(nextState); setState(nextState, true);
} }
state = nextState; state = nextState;
@@ -102,7 +103,9 @@ export default function useNavigationBuilder(
React.useEffect(() => { React.useEffect(() => {
return () => { return () => {
// We need to clean up state for this navigator on unmount // We need to clean up state for this navigator on unmount
getCurrentState() !== undefined && setState(undefined); performTransaction(
() => getCurrentState() !== undefined && setState(undefined)
);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);

View File

@@ -7,6 +7,7 @@ import {
NavigationState, NavigationState,
ActionCreators, ActionCreators,
} from './types'; } from './types';
import { NavigationStateContext } from './NavigationContainer';
type Options = { type Options = {
onAction: (action: NavigationAction, sourceNavigatorKey?: string) => boolean; onAction: (action: NavigationAction, sourceNavigatorKey?: string) => boolean;
@@ -25,15 +26,19 @@ export default function useNavigationHelpers({
NavigationBuilderContext NavigationBuilderContext
); );
const { performTransaction } = React.useContext(NavigationStateContext);
return React.useMemo((): NavigationHelpers => { return React.useMemo((): NavigationHelpers => {
const dispatch = ( const dispatch = (
action: NavigationAction | ((state: NavigationState) => NavigationState) action: NavigationAction | ((state: NavigationState) => NavigationState)
) => { ) => {
if (typeof action === 'function') { performTransaction(() => {
setState(action(getState())); if (typeof action === 'function') {
} else { setState(action(getState()));
onAction(action); } else {
} onAction(action);
}
});
}; };
const actions = { const actions = {
@@ -54,5 +59,12 @@ export default function useNavigationHelpers({
), ),
dispatch, dispatch,
}; };
}, [getState, onAction, parentNavigationHelpers, actionCreators, setState]); }, [
actionCreators,
parentNavigationHelpers,
performTransaction,
setState,
getState,
onAction,
]);
} }