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

Commit 0cd6fd2

Browse files
vonovakbrentvatne
authored andcommitted
add ability to use custom state persistence fn (#21)
1 parent c2a3c50 commit 0cd6fd2

File tree

3 files changed

+199
-27
lines changed

3 files changed

+199
-27
lines changed

src/Scrollables.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import React from 'react';
2-
import { ScrollView, Platform, FlatList, SectionList, RefreshControl } from 'react-native';
2+
import {
3+
ScrollView,
4+
Platform,
5+
FlatList,
6+
SectionList,
7+
RefreshControl,
8+
} from 'react-native';
39
import { ScrollView as GHScrollView } from 'react-native-gesture-handler';
410
import createNavigationAwareScrollable from './createNavigationAwareScrollable';
511
import invariant from './utils/invariant';

src/__tests__/createAppContainer-test.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,4 +272,132 @@ describe('NavigationContainer', () => {
272272
});
273273
});
274274
});
275+
276+
// https://github.com/facebook/jest/issues/2157#issuecomment-279171856
277+
const flushPromises = () => new Promise(resolve => setImmediate(resolve));
278+
279+
describe('state persistence', () => {
280+
async function createPersistenceEnabledContainer(
281+
loadNavigationState,
282+
persistNavigationState = jest.fn()
283+
) {
284+
const navContainer = renderer
285+
.create(
286+
<NavigationContainer
287+
persistNavigationState={persistNavigationState}
288+
loadNavigationState={loadNavigationState}
289+
/>
290+
)
291+
.getInstance();
292+
293+
// wait for loadNavigationState() to resolve
294+
await flushPromises();
295+
return navContainer;
296+
}
297+
298+
it('loadNavigationState is called upon mount and persistNavigationState is called on a nav state change', async () => {
299+
const persistNavigationState = jest.fn();
300+
const loadNavigationState = jest.fn().mockResolvedValue({
301+
index: 1,
302+
routes: [{ routeName: 'foo' }, { routeName: 'bar' }],
303+
});
304+
305+
const navigationContainer = await createPersistenceEnabledContainer(
306+
loadNavigationState,
307+
persistNavigationState
308+
);
309+
expect(loadNavigationState).toHaveBeenCalled();
310+
311+
// wait for setState done
312+
jest.runOnlyPendingTimers();
313+
314+
navigationContainer.dispatch(
315+
NavigationActions.navigate({ routeName: 'foo' })
316+
);
317+
jest.runOnlyPendingTimers();
318+
expect(persistNavigationState).toHaveBeenCalledWith({
319+
index: 0,
320+
isTransitioning: true,
321+
routes: [{ routeName: 'foo' }],
322+
});
323+
});
324+
325+
it('when persistNavigationState rejects, a console warning is shown', async () => {
326+
const consoleSpy = jest.spyOn(console, 'warn');
327+
const persistNavigationState = jest
328+
.fn()
329+
.mockRejectedValue(new Error('serialization failed'));
330+
const loadNavigationState = jest.fn().mockResolvedValue(null);
331+
332+
const navigationContainer = await createPersistenceEnabledContainer(
333+
loadNavigationState,
334+
persistNavigationState
335+
);
336+
337+
// wait for setState done
338+
jest.runOnlyPendingTimers();
339+
340+
navigationContainer.dispatch(
341+
NavigationActions.navigate({ routeName: 'baz' })
342+
);
343+
jest.runOnlyPendingTimers();
344+
await flushPromises();
345+
346+
expect(consoleSpy).toHaveBeenCalledWith(expect.any(String));
347+
});
348+
349+
it('when loadNavigationState rejects, navigator ignores the rejection and starts from the initial state', async () => {
350+
const loadNavigationState = jest
351+
.fn()
352+
.mockRejectedValue(new Error('deserialization failed'));
353+
354+
const navigationContainer = await createPersistenceEnabledContainer(
355+
loadNavigationState
356+
);
357+
358+
expect(loadNavigationState).toHaveBeenCalled();
359+
360+
// wait for setState done
361+
jest.runOnlyPendingTimers();
362+
363+
expect(navigationContainer.state.nav).toMatchObject({
364+
index: 0,
365+
isTransitioning: false,
366+
key: 'StackRouterRoot',
367+
routes: [{ routeName: 'foo' }],
368+
});
369+
});
370+
371+
// this test is skipped because the componentDidCatch recovery logic does not work as intended
372+
it.skip('when loadNavigationState resolves with an invalid nav state object, navigator starts from the initial state', async () => {
373+
const loadNavigationState = jest.fn().mockResolvedValue({
374+
index: 20,
375+
routes: [{ routeName: 'foo' }, { routeName: 'bar' }],
376+
});
377+
378+
const navigationContainer = await createPersistenceEnabledContainer(
379+
loadNavigationState
380+
);
381+
382+
expect(loadNavigationState).toHaveBeenCalled();
383+
384+
// wait for setState done
385+
jest.runOnlyPendingTimers();
386+
387+
expect(navigationContainer.state.nav).toMatchObject({
388+
index: 0,
389+
isTransitioning: false,
390+
key: 'StackRouterRoot',
391+
routes: [{ routeName: 'foo' }],
392+
});
393+
});
394+
395+
it('throws when persistNavigationState and loadNavigationState do not pass validation', () => {
396+
expect(() =>
397+
renderer.create(
398+
<NavigationContainer persistNavigationState={jest.fn()} />
399+
)
400+
).toThrow();
401+
});
402+
});
275403
});

src/createAppContainer.js

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React from 'react';
2-
import { AsyncStorage, Linking, Platform, BackHandler } from 'react-native';
3-
2+
import { Linking, Platform, BackHandler } from 'react-native';
43
import {
54
NavigationActions,
65
pathUtils,
@@ -17,11 +16,26 @@ function isStateful(props) {
1716
}
1817

1918
function validateProps(props) {
19+
if (props.persistenceKey) {
20+
console.warn(
21+
'You passed persistenceKey prop to a navigator. ' +
22+
'The persistenceKey prop was replaced by a more flexible persistence mechanism, ' +
23+
'please see the navigation state persistence docs for more information. ' +
24+
'Passing the persistenceKey prop is a no-op.'
25+
);
26+
}
2027
if (isStateful(props)) {
2128
return;
2229
}
23-
// eslint-disable-next-line no-unused-vars
24-
const { navigation, screenProps, ...containerProps } = props;
30+
/* eslint-disable no-unused-vars */
31+
const {
32+
navigation,
33+
screenProps,
34+
persistNavigationState,
35+
loadNavigationState,
36+
...containerProps
37+
} = props;
38+
/* eslint-enable no-unused-vars */
2539

2640
const keys = Object.keys(containerProps);
2741

@@ -35,6 +49,13 @@ function validateProps(props) {
3549
'navigator should maintain its own state, do not pass a navigation prop.'
3650
);
3751
}
52+
invariant(
53+
(persistNavigationState === undefined &&
54+
loadNavigationState === undefined) ||
55+
(typeof persistNavigationState === 'function' &&
56+
typeof loadNavigationState === 'function'),
57+
'both persistNavigationState and loadNavigationState must either be undefined, or be functions'
58+
);
3859
}
3960

4061
// Track the number of stateful container instances. Warn if >0 and not using the
@@ -100,7 +121,7 @@ export default function createNavigationContainer(Component) {
100121

101122
this.state = {
102123
nav:
103-
this._isStateful() && !props.persistenceKey
124+
this._isStateful() && !props.loadNavigationState
104125
? Component.router.getStateForAction(this._initialAction)
105126
: null,
106127
};
@@ -210,35 +231,30 @@ export default function createNavigationContainer(Component) {
210231
Linking.addEventListener('url', this._handleOpenURL);
211232

212233
// Pull out anything that can impact state
213-
const { persistenceKey, uriPrefix, enableURLHandling } = this.props;
214234
let parsedUrl = null;
215-
let startupStateJSON = null;
216-
if (enableURLHandling !== false) {
217-
startupStateJSON =
218-
persistenceKey && (await AsyncStorage.getItem(persistenceKey));
219-
const url = await Linking.getInitialURL();
220-
parsedUrl = url && urlToPathAndParams(url, uriPrefix);
235+
let userProvidedStartupState = null;
236+
if (this.props.enableURLHandling !== false) {
237+
({
238+
parsedUrl,
239+
userProvidedStartupState,
240+
} = await this.getStartupParams());
221241
}
222242

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

234-
// Pull persisted state from AsyncStorage
235-
if (startupStateJSON) {
236-
try {
237-
startupState = JSON.parse(startupStateJSON);
238-
_reactNavigationIsHydratingState = true;
239-
} catch (e) {
240-
/* do nothing */
241-
}
254+
// Pull user-provided persisted state
255+
if (userProvidedStartupState) {
256+
startupState = userProvidedStartupState;
257+
_reactNavigationIsHydratingState = true;
242258
}
243259

244260
// Pull state out of URL
@@ -284,11 +300,28 @@ export default function createNavigationContainer(Component) {
284300
});
285301
}
286302

303+
async getStartupParams() {
304+
const { uriPrefix, loadNavigationState } = this.props;
305+
let url, loadedNavState;
306+
try {
307+
[url, loadedNavState] = await Promise.all([
308+
Linking.getInitialURL(),
309+
loadNavigationState && loadNavigationState(),
310+
]);
311+
} catch (err) {
312+
// ignore
313+
}
314+
return {
315+
parsedUrl: url && urlToPathAndParams(url, uriPrefix),
316+
userProvidedStartupState: loadedNavState,
317+
};
318+
}
319+
287320
componentDidCatch(e) {
288321
if (_reactNavigationIsHydratingState) {
289322
_reactNavigationIsHydratingState = false;
290323
console.warn(
291-
'Uncaught exception while starting app from persisted navigation state! Trying to render again with a fresh navigation state..'
324+
'Uncaught exception while starting app from persisted navigation state! Trying to render again with a fresh navigation state...'
292325
);
293326
this.dispatch(NavigationActions.init());
294327
} else {
@@ -297,11 +330,16 @@ export default function createNavigationContainer(Component) {
297330
}
298331

299332
_persistNavigationState = async nav => {
300-
const { persistenceKey } = this.props;
301-
if (!persistenceKey) {
302-
return;
333+
const { persistNavigationState } = this.props;
334+
if (persistNavigationState) {
335+
try {
336+
await persistNavigationState(nav);
337+
} catch (err) {
338+
console.warn(
339+
'Uncaught exception while calling persistNavigationState()! You should handle exceptions thrown from persistNavigationState(), ignoring them may result in undefined behavior.'
340+
);
341+
}
303342
}
304-
await AsyncStorage.setItem(persistenceKey, JSON.stringify(nav));
305343
};
306344

307345
componentWillUnmount() {

0 commit comments

Comments
 (0)