navigation-stack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.babelrc.cjs +17 -0
- package/.eslintignore +8 -0
- package/.eslintrc.cjs +10 -0
- package/.github/workflows/main.yml +39 -0
- package/.yarn/install-state.gz +0 -0
- package/.yarnrc.yml +1 -0
- package/CODE_OF_CONDUCT.md +77 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/codecov.yml +1 -0
- package/karma.conf.cjs +63 -0
- package/lib/cjs/ActionTypes.js +14 -0
- package/lib/cjs/Actions.js +27 -0
- package/lib/cjs/LocationStateStorage.js +60 -0
- package/lib/cjs/addNavigationBlocker.js +7 -0
- package/lib/cjs/basePath.js +58 -0
- package/lib/cjs/createMiddlewares.js +43 -0
- package/lib/cjs/createSearchFromQuery.js +13 -0
- package/lib/cjs/environment/BrowserEnvironment.js +111 -0
- package/lib/cjs/environment/MemoryEnvironment.js +150 -0
- package/lib/cjs/environment/ServerEnvironment.js +53 -0
- package/lib/cjs/getLocationUrl.js +20 -0
- package/lib/cjs/index.js +30 -0
- package/lib/cjs/isPromise.js +9 -0
- package/lib/cjs/locationReducer.js +13 -0
- package/lib/cjs/middleware/createBasePathMiddleware.js +24 -0
- package/lib/cjs/middleware/createEnvironmentMiddleware.js +58 -0
- package/lib/cjs/middleware/createNavigationBlockerMiddleware.js +128 -0
- package/lib/cjs/middleware/createTransformLocationMiddleware.js +38 -0
- package/lib/cjs/middleware/navigationActionMiddleware.js +37 -0
- package/lib/cjs/middleware/normalizeInputLocationMiddleware.js +27 -0
- package/lib/cjs/navigationBlockers.js +146 -0
- package/lib/cjs/normalizeInputLocation.js +46 -0
- package/lib/cjs/onlyAllowedOnClientSide.js +10 -0
- package/lib/cjs/parseLocationUrl.js +39 -0
- package/lib/cjs/parseQueryFromSearch.js +16 -0
- package/lib/esm/ActionTypes.js +9 -0
- package/lib/esm/Actions.js +21 -0
- package/lib/esm/LocationStateStorage.js +53 -0
- package/lib/esm/addNavigationBlocker.js +2 -0
- package/lib/esm/basePath.js +53 -0
- package/lib/esm/createMiddlewares.js +37 -0
- package/lib/esm/createSearchFromQuery.js +8 -0
- package/lib/esm/environment/BrowserEnvironment.js +104 -0
- package/lib/esm/environment/MemoryEnvironment.js +143 -0
- package/lib/esm/environment/ServerEnvironment.js +46 -0
- package/lib/esm/getLocationUrl.js +15 -0
- package/lib/esm/index.js +12 -0
- package/lib/esm/isPromise.js +4 -0
- package/lib/esm/locationReducer.js +7 -0
- package/lib/esm/middleware/createBasePathMiddleware.js +19 -0
- package/lib/esm/middleware/createEnvironmentMiddleware.js +52 -0
- package/lib/esm/middleware/createNavigationBlockerMiddleware.js +123 -0
- package/lib/esm/middleware/createTransformLocationMiddleware.js +33 -0
- package/lib/esm/middleware/navigationActionMiddleware.js +32 -0
- package/lib/esm/middleware/normalizeInputLocationMiddleware.js +22 -0
- package/lib/esm/navigationBlockers.js +138 -0
- package/lib/esm/normalizeInputLocation.js +41 -0
- package/lib/esm/onlyAllowedOnClientSide.js +5 -0
- package/lib/esm/parseLocationUrl.js +33 -0
- package/lib/esm/parseQueryFromSearch.js +11 -0
- package/lib/index.d.ts +301 -0
- package/package.json +100 -0
- package/renovate.json +3 -0
- package/src/ActionTypes.js +9 -0
- package/src/Actions.js +26 -0
- package/src/LocationStateStorage.js +59 -0
- package/src/addNavigationBlocker.js +2 -0
- package/src/basePath.js +65 -0
- package/src/createMiddlewares.js +41 -0
- package/src/createSearchFromQuery.js +9 -0
- package/src/environment/BrowserEnvironment.js +109 -0
- package/src/environment/MemoryEnvironment.js +151 -0
- package/src/environment/ServerEnvironment.js +54 -0
- package/src/getLocationUrl.js +12 -0
- package/src/index.js +12 -0
- package/src/isPromise.js +8 -0
- package/src/locationReducer.js +8 -0
- package/src/middleware/createBasePathMiddleware.js +20 -0
- package/src/middleware/createEnvironmentMiddleware.js +57 -0
- package/src/middleware/createNavigationBlockerMiddleware.js +128 -0
- package/src/middleware/createTransformLocationMiddleware.js +29 -0
- package/src/middleware/navigationActionMiddleware.js +27 -0
- package/src/middleware/normalizeInputLocationMiddleware.js +21 -0
- package/src/navigationBlockers.js +158 -0
- package/src/normalizeInputLocation.js +44 -0
- package/src/onlyAllowedOnClientSide.js +5 -0
- package/src/parseLocationUrl.js +40 -0
- package/src/parseQueryFromSearch.js +12 -0
- package/test/.eslintrc.cjs +17 -0
- package/test/Action.test.js +72 -0
- package/test/ActionTypes.test.js +13 -0
- package/test/LocationStateStorage.test.js +75 -0
- package/test/basePath.test.js +158 -0
- package/test/createMiddlewares.test.js +62 -0
- package/test/environment/BrowserEnvironment.test.js +165 -0
- package/test/environment/MemoryEnvironment.test.js +218 -0
- package/test/environment/ServerEnvironment.test.js +23 -0
- package/test/getLocationUrl.test.js +33 -0
- package/test/helpers.js +34 -0
- package/test/index.js +44 -0
- package/test/index.test.js +20 -0
- package/test/locationReducer.test.js +42 -0
- package/test/middleware/createBasePathMiddleware.test.js +67 -0
- package/test/middleware/createNavigationBlockerMiddleware.test.js +472 -0
- package/test/middleware/createTransformLocationMiddleware.test.js +44 -0
- package/test/middleware/navigationActionMiddleware.test.js +74 -0
- package/test/middleware/normalizeInputLocationMiddleware.test.js +62 -0
- package/test/normalizeInputLocation.test.js +81 -0
- package/test/parseLocationUrl.test.js +30 -0
- package/types/.eslintrc.cjs +3 -0
- package/types/index.d.ts +301 -0
- package/types/tsconfig.json +14 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import normalizeInputLocation from '../normalizeInputLocation';
|
|
2
|
+
export default class MemoryEnvironment {
|
|
3
|
+
constructor(initialLocation, {
|
|
4
|
+
save,
|
|
5
|
+
load
|
|
6
|
+
} = {}) {
|
|
7
|
+
this._save = save;
|
|
8
|
+
this._load = load;
|
|
9
|
+
const initialState = load ? this._loadState() : null;
|
|
10
|
+
if (initialState) {
|
|
11
|
+
this._stack = initialState.stack;
|
|
12
|
+
this._index = initialState.index;
|
|
13
|
+
this._state = initialState.state;
|
|
14
|
+
} else {
|
|
15
|
+
this._stack = [normalizeInputLocation(initialLocation)];
|
|
16
|
+
this._index = 0;
|
|
17
|
+
this._state = {};
|
|
18
|
+
}
|
|
19
|
+
this._keyPrefix = Math.random().toString(36).slice(2, 8);
|
|
20
|
+
this._keyIndex = 0;
|
|
21
|
+
this._listener = null;
|
|
22
|
+
}
|
|
23
|
+
_loadState() {
|
|
24
|
+
try {
|
|
25
|
+
const {
|
|
26
|
+
stack,
|
|
27
|
+
index,
|
|
28
|
+
state
|
|
29
|
+
} = JSON.parse(this._load());
|
|
30
|
+
|
|
31
|
+
// Check that the stack and index at least seem reasonable before using
|
|
32
|
+
// them as state. This isn't foolproof, but it might prevent mistakes.
|
|
33
|
+
// Also perform a basic validation of `state`.
|
|
34
|
+
if (Array.isArray(stack) && typeof index === 'number' && stack[index] && typeof state === 'object' && state !== null) {
|
|
35
|
+
return {
|
|
36
|
+
stack,
|
|
37
|
+
index,
|
|
38
|
+
state
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {} // eslint-disable-line no-empty
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
init(delta = 0) {
|
|
46
|
+
return Object.assign({}, this._stack[this._index], {
|
|
47
|
+
action: 'POP',
|
|
48
|
+
index: this._index,
|
|
49
|
+
delta
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
subscribe(listener) {
|
|
53
|
+
this._listener = listener;
|
|
54
|
+
return () => {
|
|
55
|
+
this._listener = null;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
navigate(location) {
|
|
59
|
+
const {
|
|
60
|
+
action,
|
|
61
|
+
pathname,
|
|
62
|
+
search,
|
|
63
|
+
query,
|
|
64
|
+
hash,
|
|
65
|
+
state
|
|
66
|
+
} = location;
|
|
67
|
+
const push = action === 'PUSH';
|
|
68
|
+
if (!push && action !== 'REPLACE') throw Error(`Unrecognized browser environment action: ${action}`);
|
|
69
|
+
const delta = push ? 1 : 0;
|
|
70
|
+
this._index += delta;
|
|
71
|
+
const keyIndex = this._keyIndex++;
|
|
72
|
+
const key = `${this._keyPrefix}:${keyIndex.toString(36)}`;
|
|
73
|
+
this._stack[this._index] = {
|
|
74
|
+
pathname,
|
|
75
|
+
search,
|
|
76
|
+
query,
|
|
77
|
+
hash,
|
|
78
|
+
state,
|
|
79
|
+
key
|
|
80
|
+
};
|
|
81
|
+
if (push) {
|
|
82
|
+
this._stack.length = this._index + 1;
|
|
83
|
+
}
|
|
84
|
+
if (this._save) {
|
|
85
|
+
this._saveState();
|
|
86
|
+
}
|
|
87
|
+
return Object.assign({}, location, {
|
|
88
|
+
key,
|
|
89
|
+
index: this._index,
|
|
90
|
+
delta
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
shift(delta) {
|
|
94
|
+
const prevIndex = this._index;
|
|
95
|
+
this._index = Math.min(Math.max(this._index + delta, 0), this._stack.length - 1);
|
|
96
|
+
if (this._index === prevIndex) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (this._save) {
|
|
100
|
+
this._saveState();
|
|
101
|
+
}
|
|
102
|
+
if (this._listener) {
|
|
103
|
+
this._listener(this.init(this._index - prevIndex));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// "Before destroy" listeners are currently ignored.
|
|
108
|
+
// If required, one could implement a `_destroy()` method
|
|
109
|
+
// and there check that the listeners actually do get called.
|
|
110
|
+
addBeforeDestroyListener(listener) {
|
|
111
|
+
return () => {
|
|
112
|
+
this._removeBeforeDestroyListener(listener);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// This method is used in tests.
|
|
117
|
+
_removeBeforeDestroyListener() {}
|
|
118
|
+
_saveState() {
|
|
119
|
+
try {
|
|
120
|
+
this._save(JSON.stringify({
|
|
121
|
+
stack: this._stack,
|
|
122
|
+
index: this._index,
|
|
123
|
+
state: this._state
|
|
124
|
+
}));
|
|
125
|
+
} catch (error) {} // eslint-disable-line no-empty
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Returns either a `string` value or `null` if the key doesn't exist.
|
|
129
|
+
getState(key) {
|
|
130
|
+
if (key in this._state) {
|
|
131
|
+
return this._state[key];
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
removeState(key) {
|
|
136
|
+
if (key in this._state) {
|
|
137
|
+
delete this._state[key];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
setState(key, value) {
|
|
141
|
+
this._state[key] = value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import normalizeInputLocation from '../normalizeInputLocation';
|
|
2
|
+
function noop() {}
|
|
3
|
+
function serverSideNavigationNotPossible() {
|
|
4
|
+
throw new Error('Server-side navigation is not possible');
|
|
5
|
+
}
|
|
6
|
+
export default class ServerEnvironment {
|
|
7
|
+
constructor(initialLocation) {
|
|
8
|
+
this._location = normalizeInputLocation(initialLocation);
|
|
9
|
+
}
|
|
10
|
+
init() {
|
|
11
|
+
return Object.assign({
|
|
12
|
+
action: 'POP'
|
|
13
|
+
}, this._location);
|
|
14
|
+
}
|
|
15
|
+
subscribe() {
|
|
16
|
+
// Server environment emits no location events.
|
|
17
|
+
return noop;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Navigation methods are not implemented, because `ServerPEnvironment` instances
|
|
21
|
+
// cannot navigate.
|
|
22
|
+
navigate() {
|
|
23
|
+
serverSideNavigationNotPossible();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Navigation methods are not implemented, because `ServerEnvironment` instances
|
|
27
|
+
// cannot navigate.
|
|
28
|
+
shift() {
|
|
29
|
+
serverSideNavigationNotPossible();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// "Before destroy" listeners are currently ignored.
|
|
33
|
+
// If required, one could implement a `_destroy()` method
|
|
34
|
+
// and there check that the listeners actually do get called.
|
|
35
|
+
addBeforeDestroyListener() {
|
|
36
|
+
return () => {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// It doesn't seem to make any sense to store anything on server side.
|
|
40
|
+
// Hence, state management methods are "no op" stubs.
|
|
41
|
+
getState() {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
removeState() {}
|
|
45
|
+
setState() {}
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { stringify as stringifyQuery } from 'query-string';
|
|
2
|
+
export default function getLocationUrl({
|
|
3
|
+
pathname,
|
|
4
|
+
search,
|
|
5
|
+
query,
|
|
6
|
+
hash
|
|
7
|
+
}) {
|
|
8
|
+
if (!search && query) {
|
|
9
|
+
const queryString = stringifyQuery(query);
|
|
10
|
+
if (queryString) {
|
|
11
|
+
search = `?${queryString}`;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return `${pathname}${search || ''}${hash || ''}`;
|
|
15
|
+
}
|
package/lib/esm/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { default as Actions } from './Actions';
|
|
2
|
+
export { default as ActionTypes } from './ActionTypes';
|
|
3
|
+
export { addBasePath, removeBasePath } from './basePath';
|
|
4
|
+
export { default as addNavigationBlocker } from './addNavigationBlocker';
|
|
5
|
+
export { default as getLocationUrl } from './getLocationUrl';
|
|
6
|
+
export { default as parseLocationUrl } from './parseLocationUrl';
|
|
7
|
+
export { default as createMiddlewares } from './createMiddlewares';
|
|
8
|
+
export { default as locationReducer } from './locationReducer';
|
|
9
|
+
export { default as LocationStateStorage } from './LocationStateStorage';
|
|
10
|
+
export { default as BrowserEnvironment } from './environment/BrowserEnvironment';
|
|
11
|
+
export { default as MemoryEnvironment } from './environment/MemoryEnvironment';
|
|
12
|
+
export { default as ServerEnvironment } from './environment/ServerEnvironment';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { addBasePath, removeBasePath } from '../basePath';
|
|
2
|
+
import createTransformLocationMiddleware from './createTransformLocationMiddleware';
|
|
3
|
+
|
|
4
|
+
// Creates a "middleware" that, when a website is hosted under a certain path (`basePath`),
|
|
5
|
+
// automatically strips that starting segment from the `pathname` of `location`s.
|
|
6
|
+
export default function createBasePathMiddleware(basePath) {
|
|
7
|
+
return createTransformLocationMiddleware({
|
|
8
|
+
// Transforms input `Location`:
|
|
9
|
+
// prepends `basePath` to the URL.
|
|
10
|
+
transformInputLocation: location => {
|
|
11
|
+
return addBasePath(location, basePath);
|
|
12
|
+
},
|
|
13
|
+
// Transforms environment `Location` object:
|
|
14
|
+
// removes `basePath` from the URL.
|
|
15
|
+
transformEnvironmentLocation: location => {
|
|
16
|
+
return removeBasePath(location, basePath);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import ActionTypes from '../ActionTypes';
|
|
2
|
+
function updateLocation(location) {
|
|
3
|
+
return {
|
|
4
|
+
type: ActionTypes.UPDATE,
|
|
5
|
+
payload: location
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Creates a "middleware" that performs the actual navigation according to the `environment` being used.
|
|
10
|
+
// For example, when `BrowserProtocol` is used, it calls methods of the `history` object.
|
|
11
|
+
// A better name for this function could be something like `createProtocolMiddleware(environment)`.
|
|
12
|
+
// A better name for "environment" could be something like "environment".
|
|
13
|
+
export default function createEnvironmentMiddleware(environment, {
|
|
14
|
+
shouldIgnoreEnvironmentLocationUpdates
|
|
15
|
+
}) {
|
|
16
|
+
return function environmentMiddleware() {
|
|
17
|
+
return next => {
|
|
18
|
+
// Whenever browser location changes,
|
|
19
|
+
// perform the same changes with the internal `location` object.
|
|
20
|
+
const unsubscribe = environment.subscribe(location => {
|
|
21
|
+
if (!shouldIgnoreEnvironmentLocationUpdates()) {
|
|
22
|
+
next(updateLocation(location));
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return action => {
|
|
26
|
+
const {
|
|
27
|
+
type,
|
|
28
|
+
payload
|
|
29
|
+
} = action;
|
|
30
|
+
switch (type) {
|
|
31
|
+
case ActionTypes.INIT:
|
|
32
|
+
return next(updateLocation(environment.init()));
|
|
33
|
+
case ActionTypes.NAVIGATE:
|
|
34
|
+
// `environment.navigate()` doesn't trigger the `subscribe()` listener.
|
|
35
|
+
return next(updateLocation(environment.navigate(payload)));
|
|
36
|
+
case ActionTypes.SHIFT:
|
|
37
|
+
// `shift()` will trigger the `subscribe()` listener,
|
|
38
|
+
// which will call `updateLocation()`.
|
|
39
|
+
environment.shift(payload);
|
|
40
|
+
// eslint-disable-next-line consistent-return
|
|
41
|
+
return;
|
|
42
|
+
case ActionTypes.DISPOSE:
|
|
43
|
+
unsubscribe();
|
|
44
|
+
// eslint-disable-next-line consistent-return
|
|
45
|
+
return;
|
|
46
|
+
default:
|
|
47
|
+
return next(action);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import ActionTypes from '../ActionTypes';
|
|
2
|
+
import isPromise from '../isPromise';
|
|
3
|
+
import { getNavigationBlockers, removeAllNavigationBlockers, runNavigationBlockers } from '../navigationBlockers';
|
|
4
|
+
import onlyAllowedOnClientSide from '../onlyAllowedOnClientSide';
|
|
5
|
+
|
|
6
|
+
// Creates a "middleware" that applies navigation blockers.
|
|
7
|
+
export default function createNavigationBlockerMiddleware(environment, {
|
|
8
|
+
ignoreEnvironmentLocationUpdates
|
|
9
|
+
}) {
|
|
10
|
+
// A "dummy" initial value that will be ignored.
|
|
11
|
+
let navigationBlockersEvaluationStatus = {
|
|
12
|
+
cancelled: false
|
|
13
|
+
};
|
|
14
|
+
function createNavigationBlockersEvaluationStatus() {
|
|
15
|
+
onlyAllowedOnClientSide();
|
|
16
|
+
navigationBlockersEvaluationStatus.cancelled = true;
|
|
17
|
+
navigationBlockersEvaluationStatus = {
|
|
18
|
+
cancelled: false
|
|
19
|
+
};
|
|
20
|
+
return navigationBlockersEvaluationStatus;
|
|
21
|
+
}
|
|
22
|
+
return function navigationListenerMiddleware() {
|
|
23
|
+
return next => action => {
|
|
24
|
+
const {
|
|
25
|
+
type,
|
|
26
|
+
payload
|
|
27
|
+
} = action;
|
|
28
|
+
|
|
29
|
+
// Declaring `result` variable here fixes ESLint error:
|
|
30
|
+
// "Unexpected lexical declaration in case block".
|
|
31
|
+
let result;
|
|
32
|
+
switch (type) {
|
|
33
|
+
// Prevent or allow navigation that was initiated by the application.
|
|
34
|
+
case ActionTypes.NAVIGATE:
|
|
35
|
+
// `resultValue` variable name works around a stupid javascript error:
|
|
36
|
+
// "Cannot redeclare block-scoped variable 'result'".
|
|
37
|
+
result = runNavigationBlockers(getNavigationBlockers(), payload);
|
|
38
|
+
if (isPromise(result)) {
|
|
39
|
+
const status = createNavigationBlockersEvaluationStatus();
|
|
40
|
+
// eslint-disable-next-line consistent-return
|
|
41
|
+
result.then(resultValue => {
|
|
42
|
+
if (!status.cancelled) {
|
|
43
|
+
if (!resultValue) {
|
|
44
|
+
return next(action);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
} else if (!result) {
|
|
49
|
+
return next(action);
|
|
50
|
+
}
|
|
51
|
+
// eslint-disable-next-line consistent-return
|
|
52
|
+
return;
|
|
53
|
+
|
|
54
|
+
// Prevent or allow navigation that was initiated by the environment itself.
|
|
55
|
+
// For example, in a web browser, it could happen when the user clicks the "Back"/"Forward" buttons.
|
|
56
|
+
//
|
|
57
|
+
// In this scenario, the web browser is already at the new location, i.e. the navigation has already happened.
|
|
58
|
+
// It could be "prevented" by rewinding back to the previous location.
|
|
59
|
+
//
|
|
60
|
+
case ActionTypes.UPDATE:
|
|
61
|
+
// If no navigation blockers to run, don't do anything.
|
|
62
|
+
if (getNavigationBlockers().length === 0) {
|
|
63
|
+
return next(action);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// If it was the initial page load or a redirect,
|
|
67
|
+
// it's not really a navigation that could be rolled back.
|
|
68
|
+
if (payload.delta === 0) {
|
|
69
|
+
return next(action);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// It's not really possible for a location to not have a `delta` property in a web browser environment.
|
|
73
|
+
// So this case is not something that's supposed to happen in real life.
|
|
74
|
+
// Rather, it's a guard against an unsupported or incorrectly-implemented environment or something like that.
|
|
75
|
+
// If there's no `delta` property on the location, it means that the previous location can't be rewound to,
|
|
76
|
+
// so it can't really "prevent" the navigation that has just happened.
|
|
77
|
+
if (payload.delta === null) {
|
|
78
|
+
return next(action);
|
|
79
|
+
}
|
|
80
|
+
result = runNavigationBlockers(getNavigationBlockers(), payload);
|
|
81
|
+
if (isPromise(result)) {
|
|
82
|
+
const status = createNavigationBlockersEvaluationStatus();
|
|
83
|
+
|
|
84
|
+
// While location blockers are running, rewind to the previous location.
|
|
85
|
+
ignoreEnvironmentLocationUpdates(() => {
|
|
86
|
+
environment.shift(-payload.delta);
|
|
87
|
+
});
|
|
88
|
+
result.then(promiseResult => {
|
|
89
|
+
if (promiseResult) {
|
|
90
|
+
// Navigation blocked.
|
|
91
|
+
// Already rewound to a previous location.
|
|
92
|
+
} else if (!status.cancelled) {
|
|
93
|
+
// Navigation not blocked.
|
|
94
|
+
// Rewind back to the new location.
|
|
95
|
+
ignoreEnvironmentLocationUpdates(() => {
|
|
96
|
+
environment.shift(payload.delta);
|
|
97
|
+
});
|
|
98
|
+
// Update the location.
|
|
99
|
+
next(action);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
} else if (result) {
|
|
103
|
+
// Prevent the navigation: rewind to the previous location.
|
|
104
|
+
ignoreEnvironmentLocationUpdates(() => {
|
|
105
|
+
environment.shift(-payload.delta);
|
|
106
|
+
});
|
|
107
|
+
} else {
|
|
108
|
+
// Update the location.
|
|
109
|
+
return next(action);
|
|
110
|
+
}
|
|
111
|
+
// eslint-disable-next-line consistent-return
|
|
112
|
+
return;
|
|
113
|
+
|
|
114
|
+
// Remove any navigation blockers on `DISPOSE` event.
|
|
115
|
+
case ActionTypes.DISPOSE:
|
|
116
|
+
removeAllNavigationBlockers();
|
|
117
|
+
return next(action);
|
|
118
|
+
default:
|
|
119
|
+
return next(action);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import ActionTypes from '../ActionTypes';
|
|
2
|
+
|
|
3
|
+
// Creates a "middleware" that transforms action payload (location).
|
|
4
|
+
export default function createTransformLocationMiddleware({
|
|
5
|
+
transformInputLocation,
|
|
6
|
+
transformEnvironmentLocation
|
|
7
|
+
}) {
|
|
8
|
+
return function transformLocationMiddleware() {
|
|
9
|
+
return next => action => {
|
|
10
|
+
const {
|
|
11
|
+
type,
|
|
12
|
+
payload
|
|
13
|
+
} = action;
|
|
14
|
+
switch (type) {
|
|
15
|
+
// Transforms `NAVIGATE` action payload (`location`).
|
|
16
|
+
case ActionTypes.NAVIGATE:
|
|
17
|
+
return next({
|
|
18
|
+
type,
|
|
19
|
+
payload: transformInputLocation(payload)
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Transforms `UPDATE` action payload (input `location`).
|
|
23
|
+
case ActionTypes.UPDATE:
|
|
24
|
+
return next({
|
|
25
|
+
type,
|
|
26
|
+
payload: transformEnvironmentLocation(payload)
|
|
27
|
+
});
|
|
28
|
+
default:
|
|
29
|
+
return next(action);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import ActionTypes from '../ActionTypes';
|
|
2
|
+
|
|
3
|
+
// This "middleware" transforms a `PUSH` / `REPLACE` action into a `NAVIGATE` action.
|
|
4
|
+
export default function navigationActionMiddleware() {
|
|
5
|
+
return next => action => {
|
|
6
|
+
const {
|
|
7
|
+
type,
|
|
8
|
+
payload
|
|
9
|
+
} = action;
|
|
10
|
+
switch (type) {
|
|
11
|
+
// Converts a `PUSH` action into a `NAVIGATE` action.
|
|
12
|
+
case ActionTypes.PUSH:
|
|
13
|
+
return next({
|
|
14
|
+
type: ActionTypes.NAVIGATE,
|
|
15
|
+
payload: Object.assign({}, payload, {
|
|
16
|
+
action: 'PUSH'
|
|
17
|
+
})
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Converts a `REPLACE` action into a `NAVIGATE` action.
|
|
21
|
+
case ActionTypes.REPLACE:
|
|
22
|
+
return next({
|
|
23
|
+
type: ActionTypes.NAVIGATE,
|
|
24
|
+
payload: Object.assign({}, payload, {
|
|
25
|
+
action: 'REPLACE'
|
|
26
|
+
})
|
|
27
|
+
});
|
|
28
|
+
default:
|
|
29
|
+
return next(action);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import ActionTypes from '../ActionTypes';
|
|
2
|
+
import normalizeInputLocation from '../normalizeInputLocation';
|
|
3
|
+
|
|
4
|
+
// This "middleware" transforms input location argument into a proper `NormalizedInputLocation`.
|
|
5
|
+
export default function normalizeInputLocationMiddleware() {
|
|
6
|
+
return next => action => {
|
|
7
|
+
const {
|
|
8
|
+
type,
|
|
9
|
+
payload
|
|
10
|
+
} = action;
|
|
11
|
+
switch (type) {
|
|
12
|
+
case ActionTypes.PUSH:
|
|
13
|
+
case ActionTypes.REPLACE:
|
|
14
|
+
return next({
|
|
15
|
+
type,
|
|
16
|
+
payload: normalizeInputLocation(payload)
|
|
17
|
+
});
|
|
18
|
+
default:
|
|
19
|
+
return next(action);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import isPromise from './isPromise';
|
|
2
|
+
import onlyAllowedOnClientSide from './onlyAllowedOnClientSide';
|
|
3
|
+
let navigationBlockersList = [];
|
|
4
|
+
let removeBeforeDestroyListener;
|
|
5
|
+
export function getNavigationBlockers() {
|
|
6
|
+
return navigationBlockersList;
|
|
7
|
+
}
|
|
8
|
+
function addNavigationBlockerToTheList(blocker) {
|
|
9
|
+
onlyAllowedOnClientSide();
|
|
10
|
+
navigationBlockersList.push(blocker);
|
|
11
|
+
}
|
|
12
|
+
function removeNavigationBlockerFromTheList(blocker) {
|
|
13
|
+
onlyAllowedOnClientSide();
|
|
14
|
+
navigationBlockersList = navigationBlockersList.filter(_ => _ !== blocker);
|
|
15
|
+
}
|
|
16
|
+
export function removeAllNavigationBlockers() {
|
|
17
|
+
onlyAllowedOnClientSide();
|
|
18
|
+
if (getNavigationBlockers().some(blocker => blocker.beforeDestroy)) {
|
|
19
|
+
removeBeforeDestroyListener();
|
|
20
|
+
removeBeforeDestroyListener = undefined;
|
|
21
|
+
}
|
|
22
|
+
navigationBlockersList = [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Runs the `listener` while ignoring any errors that might be thrown by it.
|
|
26
|
+
function runNavigationBlocker({
|
|
27
|
+
listener
|
|
28
|
+
}, location) {
|
|
29
|
+
let result;
|
|
30
|
+
try {
|
|
31
|
+
result = listener(location);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// eslint-disable-next-line no-console
|
|
34
|
+
console.warn(`Ignoring navigation blocker \`${listener.name}\` that failed with \`${error}\`.`);
|
|
35
|
+
// eslint-disable-next-line no-console
|
|
36
|
+
console.error(error);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// If the listener returned a `Promise`, await for that `Promise`
|
|
40
|
+
// and then return the result.
|
|
41
|
+
if (isPromise(result)) {
|
|
42
|
+
return result.catch(error => {
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
console.warn(`Ignoring navigation blocker \`${listener.name}\` that failed with \`${error}\`.`);
|
|
45
|
+
// eslint-disable-next-line no-console
|
|
46
|
+
console.error(error);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// The listener didn't return a `Promise`.
|
|
50
|
+
// Return the "synchronous" result.
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Runs all listeners in order.
|
|
55
|
+
// If any listener returns a non-`null` result, it stops and returns the result.
|
|
56
|
+
// If there's no such listener, returns `true`.
|
|
57
|
+
export function runNavigationBlockers(navigationBlockers, toLocation) {
|
|
58
|
+
if (navigationBlockers.length === 0) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Call the first blocker in the list.
|
|
63
|
+
const result = runNavigationBlocker(navigationBlockers[0], toLocation);
|
|
64
|
+
const next = () => {
|
|
65
|
+
// Proceed to the next blocker.
|
|
66
|
+
return runNavigationBlockers(navigationBlockers.slice(1), toLocation);
|
|
67
|
+
};
|
|
68
|
+
if (isPromise(result)) {
|
|
69
|
+
return result.then(resultValue => {
|
|
70
|
+
if (resultValue) {
|
|
71
|
+
return resultValue;
|
|
72
|
+
}
|
|
73
|
+
return next();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (result) {
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
return next();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* istanbul ignore next: not testable with Karma */
|
|
83
|
+
function onBeforeDestroy() {
|
|
84
|
+
const result = runNavigationBlockers(getNavigationBlockers(), null);
|
|
85
|
+
|
|
86
|
+
// If no listener returned anything, don't prevent the "unload" event.
|
|
87
|
+
if (!result) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Web browsers don't allow displaying a custom modal in "beforeunload" phase.
|
|
92
|
+
// They only allow displaying a standard one, with the default text.
|
|
93
|
+
// Hence, "asynchronous" listeners should be ignored.
|
|
94
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
|
|
95
|
+
if (isPromise(result)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Prevent the "unload" event.
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
export function addNavigationBlocker(environment, listener) {
|
|
103
|
+
onlyAllowedOnClientSide();
|
|
104
|
+
|
|
105
|
+
// All navigation blockers also run on `beforeDestroy` event.
|
|
106
|
+
// If required, this could be a parameter of this function.
|
|
107
|
+
// The rationale could be that adding `beforeunload` a listener
|
|
108
|
+
// disables web page caching in some browsers like Firefox.
|
|
109
|
+
const beforeDestroy = true;
|
|
110
|
+
|
|
111
|
+
// If it's the first "beforeDestroy" listener, add the global `onBeforeDestroy` listener.
|
|
112
|
+
//
|
|
113
|
+
// Sidenote: Add the "beforeunload" event listener only as needed, as its presence
|
|
114
|
+
// prevents the page from being added to the page navigation cache:
|
|
115
|
+
//
|
|
116
|
+
// "In Firefox, beforeunload is not compatible with the back/forward cache (bfcache):
|
|
117
|
+
// that is, Firefox will not place pages in the bfcache if they have beforeunload listeners,
|
|
118
|
+
// and this is bad for performance."
|
|
119
|
+
//
|
|
120
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
|
|
121
|
+
if (beforeDestroy && !getNavigationBlockers().some(blocker => blocker.beforeDestroy)) {
|
|
122
|
+
removeBeforeDestroyListener = environment.addBeforeDestroyListener(onBeforeDestroy);
|
|
123
|
+
}
|
|
124
|
+
const blocker = {
|
|
125
|
+
listener,
|
|
126
|
+
beforeDestroy
|
|
127
|
+
};
|
|
128
|
+
addNavigationBlockerToTheList(blocker);
|
|
129
|
+
return () => {
|
|
130
|
+
removeNavigationBlockerFromTheList(blocker);
|
|
131
|
+
|
|
132
|
+
// If it was the last "beforeDestroy" listener, remove the global `onBeforeDestroy` listener.
|
|
133
|
+
if (beforeDestroy && !getNavigationBlockers().some(navigationBlocker => navigationBlocker.beforeDestroy)) {
|
|
134
|
+
removeBeforeDestroyListener();
|
|
135
|
+
removeBeforeDestroyListener = undefined;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|