Skip to content
This repository was archived by the owner on Feb 25, 2020. It is now read-only.

add ability to use custom state persistence fn #21

Merged
merged 11 commits into from
May 16, 2019
8 changes: 7 additions & 1 deletion src/Scrollables.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import React from 'react';
import { ScrollView, Platform, FlatList, SectionList, RefreshControl } from 'react-native';
import {
ScrollView,
Platform,
FlatList,
SectionList,
RefreshControl,
} from 'react-native';
import { ScrollView as GHScrollView } from 'react-native-gesture-handler';
import createNavigationAwareScrollable from './createNavigationAwareScrollable';
import invariant from './utils/invariant';
Expand Down
128 changes: 128 additions & 0 deletions src/__tests__/createAppContainer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,4 +272,132 @@ describe('NavigationContainer', () => {
});
});
});

// https://github.com/facebook/jest/issues/2157#issuecomment-279171856
const flushPromises = () => new Promise(resolve => setImmediate(resolve));

describe('state persistence', () => {
async function createPersistenceEnabledContainer(
loadNavigationState,
persistNavigationState = jest.fn()
) {
const navContainer = renderer
.create(
<NavigationContainer
persistNavigationState={persistNavigationState}
loadNavigationState={loadNavigationState}
/>
)
.getInstance();

// wait for loadNavigationState() to resolve
await flushPromises();
return navContainer;
}

it('loadNavigationState is called upon mount and persistNavigationState is called on a nav state change', async () => {
const persistNavigationState = jest.fn();
const loadNavigationState = jest.fn().mockResolvedValue({
index: 1,
routes: [{ routeName: 'foo' }, { routeName: 'bar' }],
});

const navigationContainer = await createPersistenceEnabledContainer(
loadNavigationState,
persistNavigationState
);
expect(loadNavigationState).toHaveBeenCalled();

// wait for setState done
jest.runOnlyPendingTimers();

navigationContainer.dispatch(
NavigationActions.navigate({ routeName: 'foo' })
);
jest.runOnlyPendingTimers();
expect(persistNavigationState).toHaveBeenCalledWith({
index: 0,
isTransitioning: true,
routes: [{ routeName: 'foo' }],
});
});

it('when persistNavigationState rejects, a console warning is shown', async () => {
const consoleSpy = jest.spyOn(console, 'warn');
const persistNavigationState = jest
.fn()
.mockRejectedValue(new Error('serialization failed'));
const loadNavigationState = jest.fn().mockResolvedValue(null);

const navigationContainer = await createPersistenceEnabledContainer(
loadNavigationState,
persistNavigationState
);

// wait for setState done
jest.runOnlyPendingTimers();

navigationContainer.dispatch(
NavigationActions.navigate({ routeName: 'baz' })
);
jest.runOnlyPendingTimers();
await flushPromises();

expect(consoleSpy).toHaveBeenCalledWith(expect.any(String));
});

it('when loadNavigationState rejects, navigator ignores the rejection and starts from the initial state', async () => {
const loadNavigationState = jest
.fn()
.mockRejectedValue(new Error('deserialization failed'));

const navigationContainer = await createPersistenceEnabledContainer(
loadNavigationState
);

expect(loadNavigationState).toHaveBeenCalled();

// wait for setState done
jest.runOnlyPendingTimers();

expect(navigationContainer.state.nav).toMatchObject({
index: 0,
isTransitioning: false,
key: 'StackRouterRoot',
routes: [{ routeName: 'foo' }],
});
});

// this test is skipped because the componentDidCatch recovery logic does not work as intended
it.skip('when loadNavigationState resolves with an invalid nav state object, navigator starts from the initial state', async () => {
const loadNavigationState = jest.fn().mockResolvedValue({
index: 20,
routes: [{ routeName: 'foo' }, { routeName: 'bar' }],
});

const navigationContainer = await createPersistenceEnabledContainer(
loadNavigationState
);

expect(loadNavigationState).toHaveBeenCalled();

// wait for setState done
jest.runOnlyPendingTimers();

expect(navigationContainer.state.nav).toMatchObject({
index: 0,
isTransitioning: false,
key: 'StackRouterRoot',
routes: [{ routeName: 'foo' }],
});
});

it('throws when persistNavigationState and loadNavigationState do not pass validation', () => {
expect(() =>
renderer.create(
<NavigationContainer persistNavigationState={jest.fn()} />
)
).toThrow();
});
});
});
90 changes: 64 additions & 26 deletions src/createAppContainer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from 'react';
import { AsyncStorage, Linking, Platform, BackHandler } from 'react-native';

import { Linking, Platform, BackHandler } from 'react-native';
import {
NavigationActions,
pathUtils,
Expand All @@ -17,11 +16,26 @@ function isStateful(props) {
}

function validateProps(props) {
if (props.persistenceKey) {
console.warn(
'You passed persistenceKey prop to a navigator. ' +
'The persistenceKey prop was replaced by a more flexible persistence mechanism, ' +
'please see the navigation state persistence docs for more information. ' +
'Passing the persistenceKey prop is a no-op.'
);
}
if (isStateful(props)) {
return;
}
// eslint-disable-next-line no-unused-vars
const { navigation, screenProps, ...containerProps } = props;
/* eslint-disable no-unused-vars */
const {
navigation,
screenProps,
persistNavigationState,
Copy link
Member Author

@vonovak vonovak Apr 17, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is a good thing, but if you keep the old diff, tests fail

loadNavigationState,
...containerProps
} = props;
/* eslint-enable no-unused-vars */

const keys = Object.keys(containerProps);

Expand All @@ -35,6 +49,13 @@ function validateProps(props) {
'navigator should maintain its own state, do not pass a navigation prop.'
);
}
invariant(
(persistNavigationState === undefined &&
loadNavigationState === undefined) ||
(typeof persistNavigationState === 'function' &&
typeof loadNavigationState === 'function'),
'both persistNavigationState and loadNavigationState must either be undefined, or be functions'
);
}

// Track the number of stateful container instances. Warn if >0 and not using the
Expand Down Expand Up @@ -96,7 +117,7 @@ export default function createNavigationContainer(Component) {

this.state = {
nav:
this._isStateful() && !props.persistenceKey
this._isStateful() && !props.loadNavigationState
? Component.router.getStateForAction(this._initialAction)
: null,
};
Expand Down Expand Up @@ -206,35 +227,30 @@ export default function createNavigationContainer(Component) {
Linking.addEventListener('url', this._handleOpenURL);

// Pull out anything that can impact state
const { persistenceKey, uriPrefix, enableURLHandling } = this.props;
let parsedUrl = null;
let startupStateJSON = null;
if (enableURLHandling !== false) {
startupStateJSON =
persistenceKey && (await AsyncStorage.getItem(persistenceKey));
const url = await Linking.getInitialURL();
parsedUrl = url && urlToPathAndParams(url, uriPrefix);
let userProvidedStartupState = null;
if (this.props.enableURLHandling !== false) {
({
parsedUrl,
userProvidedStartupState,
} = await this.getStartupParams());
}

// Initialize state. This must be done *after* any async code
// so we don't end up with a different value for this.state.nav
// due to changes while async function was resolving
let action = this._initialAction;
let startupState = this.state.nav;
if (!startupState) {
if (!startupState && !userProvidedStartupState) {
!!process.env.REACT_NAV_LOGGING &&
console.log('Init new Navigation State');
startupState = Component.router.getStateForAction(action);
}

// Pull persisted state from AsyncStorage
if (startupStateJSON) {
try {
startupState = JSON.parse(startupStateJSON);
_reactNavigationIsHydratingState = true;
} catch (e) {
/* do nothing */
}
// Pull user-provided persisted state
if (userProvidedStartupState) {
startupState = userProvidedStartupState;
_reactNavigationIsHydratingState = true;
}

// Pull state out of URL
Expand Down Expand Up @@ -280,11 +296,28 @@ export default function createNavigationContainer(Component) {
});
}

async getStartupParams() {
const { uriPrefix, loadNavigationState } = this.props;
let url, loadedNavState;
try {
[url, loadedNavState] = await Promise.all([
Linking.getInitialURL(),
loadNavigationState && loadNavigationState(),
]);
} catch (err) {
// ignore
}
return {
parsedUrl: url && urlToPathAndParams(url, uriPrefix),
userProvidedStartupState: loadedNavState,
};
}

componentDidCatch(e) {
if (_reactNavigationIsHydratingState) {
_reactNavigationIsHydratingState = false;
console.warn(
'Uncaught exception while starting app from persisted navigation state! Trying to render again with a fresh navigation state..'
'Uncaught exception while starting app from persisted navigation state! Trying to render again with a fresh navigation state...'
);
this.dispatch(NavigationActions.init());
} else {
Expand All @@ -293,11 +326,16 @@ export default function createNavigationContainer(Component) {
}

_persistNavigationState = async nav => {
const { persistenceKey } = this.props;
if (!persistenceKey) {
return;
const { persistNavigationState } = this.props;
if (persistNavigationState) {
try {
await persistNavigationState(nav);
} catch (err) {
console.warn(
'Uncaught exception while calling persistNavigationState()! You should handle exceptions thrown from persistNavigationState(), ignoring them may result in undefined behavior.'
);
}
}
await AsyncStorage.setItem(persistenceKey, JSON.stringify(nav));
};

componentWillUnmount() {
Expand Down