diff --git a/jest-setup.js b/jest-setup.js
index 37d8270..dd424be 100644
--- a/jest-setup.js
+++ b/jest-setup.js
@@ -3,6 +3,8 @@
* eslint-env jest
*/
+import React from 'react';
+
// See https://github.com/facebook/jest/issues/2208
jest.mock('Linking', () => ({
addEventListener: jest.fn(),
@@ -24,5 +26,20 @@ jest.mock('ScrollView', () => {
return ScrollView;
});
+// Mock setState so it waits using setImmediate before actually being called,
+// so we can use jest's mock timers to control it.
+// setState in the test renderer is sync instead of async like react and react-native.
+// This doesn't work with our NavigationContainer tests which test react-navigation's
+// behaviour against the async nature of setState.
+const setState = React.Component.prototype.setState;
+// $FlowExpectedError
+Object.defineProperty(React.Component.prototype, 'setState', {
+ value: function() {
+ setImmediate(() => {
+ setState.apply(this, arguments);
+ });
+ },
+});
+
// $FlowExpectedError
Date.now = jest.fn(() => 0);
diff --git a/src/__tests__/NavigationContainer-test.js b/src/__tests__/NavigationContainer-test.js
new file mode 100644
index 0000000..83cce29
--- /dev/null
+++ b/src/__tests__/NavigationContainer-test.js
@@ -0,0 +1,203 @@
+/* @flow */
+
+import React from 'react';
+import 'react-native';
+
+import renderer from 'react-test-renderer';
+
+import NavigationActions from '../NavigationActions';
+import StackNavigator from '../navigators/StackNavigator';
+
+const FooScreen = () =>
;
+const BarScreen = () => ;
+const BazScreen = () => ;
+const CarScreen = () => ;
+const DogScreen = () => ;
+const ElkScreen = () => ;
+const NavigationContainer = StackNavigator(
+ {
+ foo: {
+ screen: FooScreen,
+ },
+ bar: {
+ screen: BarScreen,
+ },
+ baz: {
+ screen: BazScreen,
+ },
+ car: {
+ screen: CarScreen,
+ },
+ dog: {
+ screen: DogScreen,
+ },
+ elk: {
+ screen: ElkScreen,
+ },
+ },
+ {
+ initialRouteName: 'foo',
+ }
+);
+
+jest.useFakeTimers();
+
+describe('NavigationContainer', () => {
+ describe('state.nav', () => {
+ it("should be preloaded with the router's initial state", () => {
+ const navigationContainer = renderer
+ .create()
+ .getInstance();
+ expect(navigationContainer.state.nav).toMatchObject({ index: 0 });
+ expect(navigationContainer.state.nav.routes).toBeInstanceOf(Array);
+ expect(navigationContainer.state.nav.routes.length).toBe(1);
+ expect(navigationContainer.state.nav.routes[0]).toMatchObject({
+ routeName: 'foo',
+ });
+ });
+ });
+
+ describe('dispatch', () => {
+ it('returns true when given a valid action', () => {
+ const navigationContainer = renderer
+ .create()
+ .getInstance();
+ jest.runOnlyPendingTimers();
+ expect(
+ navigationContainer.dispatch(
+ NavigationActions.navigate({ routeName: 'bar' })
+ )
+ ).toEqual(true);
+ });
+
+ it('returns false when given an invalid action', () => {
+ const navigationContainer = renderer
+ .create()
+ .getInstance();
+ jest.runOnlyPendingTimers();
+ expect(navigationContainer.dispatch(NavigationActions.back())).toEqual(
+ false
+ );
+ });
+
+ it('updates state.nav with an action by the next tick', () => {
+ const navigationContainer = renderer
+ .create()
+ .getInstance();
+
+ expect(
+ navigationContainer.dispatch(
+ NavigationActions.navigate({ routeName: 'bar' })
+ )
+ ).toEqual(true);
+
+ // Fake the passing of a tick
+ jest.runOnlyPendingTimers();
+
+ expect(navigationContainer.state.nav).toMatchObject({
+ index: 1,
+ routes: [{ routeName: 'foo' }, { routeName: 'bar' }],
+ });
+ });
+
+ it('does not discard actions when called twice in one tick', () => {
+ const navigationContainer = renderer
+ .create()
+ .getInstance();
+ const initialState = JSON.parse(
+ JSON.stringify(navigationContainer.state.nav)
+ );
+
+ // First dispatch
+ expect(
+ navigationContainer.dispatch(
+ NavigationActions.navigate({ routeName: 'bar' })
+ )
+ ).toEqual(true);
+
+ // Make sure that the test runner has NOT synchronously applied setState before the tick
+ expect(navigationContainer.state.nav).toMatchObject(initialState);
+
+ // Second dispatch
+ expect(
+ navigationContainer.dispatch(
+ NavigationActions.navigate({ routeName: 'baz' })
+ )
+ ).toEqual(true);
+
+ // Fake the passing of a tick
+ jest.runOnlyPendingTimers();
+
+ expect(navigationContainer.state.nav).toMatchObject({
+ index: 2,
+ routes: [
+ { routeName: 'foo' },
+ { routeName: 'bar' },
+ { routeName: 'baz' },
+ ],
+ });
+ });
+
+ it('does not discard actions when called more than 2 times in one tick', () => {
+ const navigationContainer = renderer
+ .create()
+ .getInstance();
+ const initialState = JSON.parse(
+ JSON.stringify(navigationContainer.state.nav)
+ );
+
+ // First dispatch
+ expect(
+ navigationContainer.dispatch(
+ NavigationActions.navigate({ routeName: 'bar' })
+ )
+ ).toEqual(true);
+
+ // Make sure that the test runner has NOT synchronously applied setState before the tick
+ expect(navigationContainer.state.nav).toMatchObject(initialState);
+
+ // Second dispatch
+ expect(
+ navigationContainer.dispatch(
+ NavigationActions.navigate({ routeName: 'baz' })
+ )
+ ).toEqual(true);
+
+ // Third dispatch
+ expect(
+ navigationContainer.dispatch(
+ NavigationActions.navigate({ routeName: 'car' })
+ )
+ ).toEqual(true);
+
+ // Fourth dispatch
+ expect(
+ navigationContainer.dispatch(
+ NavigationActions.navigate({ routeName: 'dog' })
+ )
+ ).toEqual(true);
+
+ // Fifth dispatch
+ expect(
+ navigationContainer.dispatch(
+ NavigationActions.navigate({ routeName: 'elk' })
+ )
+ ).toEqual(true);
+
+ // Fake the passing of a tick
+ jest.runOnlyPendingTimers();
+
+ expect(navigationContainer.state.nav).toMatchObject({
+ index: 5,
+ routes: [
+ { routeName: 'foo' },
+ { routeName: 'bar' },
+ { routeName: 'baz' },
+ { routeName: 'car' },
+ { routeName: 'dog' },
+ { routeName: 'elk' },
+ ],
+ });
+ });
+ });
+});
diff --git a/src/createNavigationContainer.js b/src/createNavigationContainer.js
index 820688e..6378fb7 100644
--- a/src/createNavigationContainer.js
+++ b/src/createNavigationContainer.js
@@ -145,6 +145,13 @@ export default function createNavigationContainer(
this._validateProps(nextProps);
}
+ componentDidUpdate() {
+ // Clear cached _nav every tick
+ if (this._nav === this.state.nav) {
+ this._nav = null;
+ }
+ }
+
componentDidMount() {
if (!this._isStateful()) {
return;
@@ -166,6 +173,9 @@ export default function createNavigationContainer(
this.subs && this.subs.remove();
}
+ // Per-tick temporary storage for state.nav
+ _nav: ?NavigationState;
+
dispatch = (inputAction: PossiblyDeprecatedNavigationAction) => {
// $FlowFixMe remove after we deprecate the old actions
const action: A = NavigationActions.mapDeprecatedActionAndWarn(
@@ -174,10 +184,13 @@ export default function createNavigationContainer(
if (!this._isStateful()) {
return false;
}
- const oldNav = this.state.nav;
+ this._nav = this._nav || this.state.nav;
+ const oldNav = this._nav;
invariant(oldNav, 'should be set in constructor if stateful');
const nav = Component.router.getStateForAction(action, oldNav);
if (nav && nav !== oldNav) {
+ // Cache updates to state.nav during the tick to ensure that subsequent calls will not discard this change
+ this._nav = nav;
this.setState({ nav }, () =>
this._onNavigationStateChange(oldNav, nav, action)
);