navigation-stack 0.1.2 → 0.2.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/.github/workflows/main.yml +39 -39
- package/README.md +128 -27
- package/lib/cjs/{LocationStateStorage.js → LocationDataStorage.js} +5 -5
- package/lib/cjs/addBeforeLocationChangeListener.js +7 -0
- package/lib/cjs/beforeLocationChangeListeners.js +51 -0
- package/lib/cjs/createMiddlewares.js +21 -17
- package/lib/cjs/index.js +9 -9
- package/lib/cjs/middleware/createBasePathMiddleware.js +2 -2
- package/lib/cjs/middleware/createBeforeLocationChangeListenerMiddleware.js +39 -0
- package/lib/cjs/middleware/{createEnvironmentMiddleware.js → createLocationMiddleware.js} +12 -14
- package/lib/cjs/middleware/createNavigationBlockerMiddleware.js +62 -29
- package/lib/cjs/middleware/createTransformLocationMiddleware.js +2 -2
- package/lib/cjs/navigationBlockers.js +55 -47
- package/lib/cjs/normalizeInputLocation.js +1 -0
- package/lib/cjs/parseLocationUrl.js +2 -0
- package/lib/cjs/parseQueryFromSearch.js +1 -1
- package/lib/cjs/session/BrowserSession.js +229 -0
- package/lib/cjs/session/MemorySession.js +223 -0
- package/lib/cjs/{environment/ServerEnvironment.js → session/ServerSession.js} +28 -16
- package/lib/esm/{LocationStateStorage.js → LocationDataStorage.js} +4 -4
- package/lib/esm/addBeforeLocationChangeListener.js +2 -0
- package/lib/esm/beforeLocationChangeListeners.js +44 -0
- package/lib/esm/createMiddlewares.js +21 -17
- package/lib/esm/index.js +4 -4
- package/lib/esm/middleware/createBasePathMiddleware.js +2 -2
- package/lib/esm/middleware/createBeforeLocationChangeListenerMiddleware.js +34 -0
- package/lib/esm/middleware/{createEnvironmentMiddleware.js → createLocationMiddleware.js} +11 -13
- package/lib/esm/middleware/createNavigationBlockerMiddleware.js +62 -29
- package/lib/esm/middleware/createTransformLocationMiddleware.js +2 -2
- package/lib/esm/navigationBlockers.js +55 -47
- package/lib/esm/normalizeInputLocation.js +1 -0
- package/lib/esm/parseLocationUrl.js +2 -0
- package/lib/esm/parseQueryFromSearch.js +1 -1
- package/lib/esm/session/BrowserSession.js +223 -0
- package/lib/esm/session/MemorySession.js +217 -0
- package/lib/esm/{environment/ServerEnvironment.js → session/ServerSession.js} +27 -15
- package/lib/index.d.ts +66 -61
- package/package.json +5 -5
- package/src/{LocationStateStorage.js → LocationDataStorage.js} +4 -4
- package/src/addBeforeLocationChangeListener.js +2 -0
- package/src/beforeLocationChangeListeners.js +54 -0
- package/src/createMiddlewares.js +21 -17
- package/src/index.js +4 -4
- package/src/middleware/createBasePathMiddleware.js +2 -2
- package/src/middleware/createBeforeLocationChangeListenerMiddleware.js +40 -0
- package/src/middleware/{createEnvironmentMiddleware.js → createLocationMiddleware.js} +12 -14
- package/src/middleware/createNavigationBlockerMiddleware.js +68 -28
- package/src/middleware/createTransformLocationMiddleware.js +2 -2
- package/src/navigationBlockers.js +68 -49
- package/src/normalizeInputLocation.js +1 -0
- package/src/parseLocationUrl.js +2 -0
- package/src/parseQueryFromSearch.js +1 -1
- package/src/session/BrowserSession.js +225 -0
- package/src/session/MemorySession.js +219 -0
- package/src/{environment/ServerEnvironment.js → session/ServerSession.js} +28 -15
- package/test/{LocationStateStorage.test.js → LocationDataStorage.test.js} +6 -6
- package/test/createMiddlewares.test.js +2 -2
- package/test/helpers.js +1 -1
- package/test/index.test.js +3 -3
- package/test/middleware/createBasePathMiddleware.test.js +7 -7
- package/test/middleware/createBeforeLocationChangeListenerMiddleware.test.js +141 -0
- package/test/middleware/createNavigationBlockerMiddleware.test.js +96 -97
- package/test/middleware/createTransformLocationMiddleware.test.js +1 -1
- package/test/normalizeInputLocation.test.js +3 -0
- package/test/parseLocationUrl.test.js +2 -0
- package/test/{environment/BrowserEnvironment.test.js → session/BrowserSession.test.js} +35 -18
- package/test/session/MemorySession.test.js +244 -0
- package/test/session/ServerSession.test.js +23 -0
- package/types/index.d.ts +64 -59
- package/lib/cjs/environment/BrowserEnvironment.js +0 -111
- package/lib/cjs/environment/MemoryEnvironment.js +0 -150
- package/lib/esm/environment/BrowserEnvironment.js +0 -104
- package/lib/esm/environment/MemoryEnvironment.js +0 -143
- package/src/environment/BrowserEnvironment.js +0 -109
- package/src/environment/MemoryEnvironment.js +0 -151
- package/test/environment/MemoryEnvironment.test.js +0 -218
- package/test/environment/ServerEnvironment.test.js +0 -23
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
import ActionTypes from '../ActionTypes';
|
|
2
2
|
import isPromise from '../isPromise';
|
|
3
3
|
import { getNavigationBlockers, removeAllNavigationBlockers, runNavigationBlockers } from '../navigationBlockers';
|
|
4
|
-
import onlyAllowedOnClientSide from '../onlyAllowedOnClientSide';
|
|
5
4
|
|
|
6
5
|
// Creates a "middleware" that applies navigation blockers.
|
|
7
|
-
export default function createNavigationBlockerMiddleware(
|
|
8
|
-
|
|
6
|
+
export default function createNavigationBlockerMiddleware(session, {
|
|
7
|
+
ignoreLocationSubscriptionEvents
|
|
9
8
|
}) {
|
|
10
|
-
// A "dummy" initial value that will be ignored.
|
|
11
|
-
let navigationBlockersEvaluationStatus = {
|
|
12
|
-
cancelled: false
|
|
13
|
-
};
|
|
14
9
|
function createNavigationBlockersEvaluationStatus() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
/* eslint-disable no-underscore-dangle */
|
|
11
|
+
if (session._navigationBlockersEvaluationStatus) {
|
|
12
|
+
session._navigationBlockersEvaluationStatus.cancelled = true;
|
|
13
|
+
}
|
|
14
|
+
session._navigationBlockersEvaluationStatus = {
|
|
18
15
|
cancelled: false
|
|
19
16
|
};
|
|
20
|
-
return
|
|
17
|
+
return session._navigationBlockersEvaluationStatus;
|
|
21
18
|
}
|
|
22
|
-
return function
|
|
19
|
+
return function navigationBlockerMiddleware() {
|
|
23
20
|
return next => action => {
|
|
24
21
|
const {
|
|
25
22
|
type,
|
|
@@ -30,11 +27,25 @@ export default function createNavigationBlockerMiddleware(environment, {
|
|
|
30
27
|
// "Unexpected lexical declaration in case block".
|
|
31
28
|
let result;
|
|
32
29
|
switch (type) {
|
|
33
|
-
// Prevent or allow navigation that was initiated by the application
|
|
30
|
+
// Prevent or allow navigation that was initiated by the application
|
|
31
|
+
// by dispatching a `.push()` or `.replace()` action.
|
|
32
|
+
//
|
|
33
|
+
// It doesn't handle `.shift()` navigation actions because it doesn't yet know
|
|
34
|
+
// the `location` that it's gonna `shift` to. Instead, it waits for the web browser
|
|
35
|
+
// to "shift" to that `location` and then reads it and rewinds the "shift"
|
|
36
|
+
// if it should've been blocked. That is handled in the `case ActionTypes.UPDATE` block.
|
|
37
|
+
//
|
|
38
|
+
// This type of "shifting" and then rewinding the "shift" doesn't really matter to the application at all.
|
|
39
|
+
// From the application's point of view, all of that doesn't matter and even doesn't exist.
|
|
40
|
+
// All that exists from the application's point of view is the `location` object in the Redux state.
|
|
41
|
+
// Until the `location` object in the Redux state is updated, the old page is still rendered.
|
|
42
|
+
// The appliation is only concerned with the updates of the `location` object in the Redux state
|
|
43
|
+
// and completely ignores any updates to the URL in the web browser's address bar.
|
|
44
|
+
//
|
|
34
45
|
case ActionTypes.NAVIGATE:
|
|
35
46
|
// `resultValue` variable name works around a stupid javascript error:
|
|
36
47
|
// "Cannot redeclare block-scoped variable 'result'".
|
|
37
|
-
result = runNavigationBlockers(getNavigationBlockers(), payload);
|
|
48
|
+
result = runNavigationBlockers(getNavigationBlockers(session), payload);
|
|
38
49
|
if (isPromise(result)) {
|
|
39
50
|
const status = createNavigationBlockersEvaluationStatus();
|
|
40
51
|
// eslint-disable-next-line consistent-return
|
|
@@ -51,15 +62,37 @@ export default function createNavigationBlockerMiddleware(environment, {
|
|
|
51
62
|
// eslint-disable-next-line consistent-return
|
|
52
63
|
return;
|
|
53
64
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
65
|
+
// One can notice that this "middleware" handles both `NAVIGATE` and `UPDATE` Redux actions,
|
|
66
|
+
// even though a `NAVIGATE` action normally always causes a follow-up `UPDATE` Redux action.
|
|
67
|
+
// There's no contradiction here: if a navigation blocker should block a certain navigation,
|
|
68
|
+
// it will do that at the `NAVIGATION` stage and it won't get to the `UPDATE` stage, so it
|
|
69
|
+
// won't be called "second time" or something like that.
|
|
70
|
+
//
|
|
71
|
+
// One could ask then: Why handle `UPDATE` Redux action at all?
|
|
72
|
+
// The reason why it handles `UPDATE` Redux actions here is because
|
|
73
|
+
// `NAVIGATE` Redux actions are only emitted for programmatic "push" or "replace" navigation
|
|
74
|
+
// initiated by the application code, and there're other cases of navigation such as
|
|
75
|
+
// programmatic "shift" navigation or when the user manually clicks "Back" or "Forward" button
|
|
76
|
+
// in a web browser. Such "other" cases could only be handled by reacting to an `UPDATE` Redux action
|
|
77
|
+
// which is only emitted after the URL in the browser's address bar has changed.
|
|
78
|
+
//
|
|
79
|
+
// But there's no real drawback in reacting to an `UPDATE` Redux action "post factum" because
|
|
80
|
+
// from the application's point of view the address bar doesn't matter and even doesn't exist.
|
|
81
|
+
// All that exists from the application's point of view is the `location` object in the Redux state.
|
|
82
|
+
// Until the `location` object in the Redux state is updated, the old page is still rendered.
|
|
83
|
+
// The appliation is only concerned with the updates of the `location` object in the Redux state
|
|
84
|
+
// and completely ignores any updates to the URL in the web browser's address bar.
|
|
56
85
|
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
86
|
+
// So here, the "middleware" attempts to prevent or allow navigation that has already happened
|
|
87
|
+
// in the web browser's address bar but hasn't yet happened in Redux state.
|
|
88
|
+
// For example, it could be a user clicking a "Back"/"Forward" button in their web browser.
|
|
89
|
+
// If such navigation should've been blocked, it will simply not update the `locaiton` object in Redux state,
|
|
90
|
+
// and it will also "rewind" the change of the URL in the web browser's address bar so that it's consistent
|
|
91
|
+
// with the `location` in Redux state.
|
|
59
92
|
//
|
|
60
93
|
case ActionTypes.UPDATE:
|
|
61
94
|
// If no navigation blockers to run, don't do anything.
|
|
62
|
-
if (getNavigationBlockers().length === 0) {
|
|
95
|
+
if (getNavigationBlockers(session).length === 0) {
|
|
63
96
|
return next(action);
|
|
64
97
|
}
|
|
65
98
|
|
|
@@ -69,21 +102,21 @@ export default function createNavigationBlockerMiddleware(environment, {
|
|
|
69
102
|
return next(action);
|
|
70
103
|
}
|
|
71
104
|
|
|
72
|
-
// It's not really possible for a location to not have a `delta` property in a web browser
|
|
105
|
+
// It's not really possible for a location to not have a `delta` property in a web browser session.
|
|
73
106
|
// So this case is not something that's supposed to happen in real life.
|
|
74
|
-
// Rather, it's a guard against an unsupported or
|
|
107
|
+
// Rather, it's a guard against an unsupported or incorrect session implementation or something like that.
|
|
75
108
|
// If there's no `delta` property on the location, it means that the previous location can't be rewound to,
|
|
76
109
|
// so it can't really "prevent" the navigation that has just happened.
|
|
77
110
|
if (payload.delta === null) {
|
|
78
111
|
return next(action);
|
|
79
112
|
}
|
|
80
|
-
result = runNavigationBlockers(getNavigationBlockers(), payload);
|
|
113
|
+
result = runNavigationBlockers(getNavigationBlockers(session), payload);
|
|
81
114
|
if (isPromise(result)) {
|
|
82
115
|
const status = createNavigationBlockersEvaluationStatus();
|
|
83
116
|
|
|
84
117
|
// While location blockers are running, rewind to the previous location.
|
|
85
|
-
|
|
86
|
-
|
|
118
|
+
ignoreLocationSubscriptionEvents(() => {
|
|
119
|
+
session.navigation.shift(-payload.delta);
|
|
87
120
|
});
|
|
88
121
|
result.then(promiseResult => {
|
|
89
122
|
if (promiseResult) {
|
|
@@ -92,8 +125,8 @@ export default function createNavigationBlockerMiddleware(environment, {
|
|
|
92
125
|
} else if (!status.cancelled) {
|
|
93
126
|
// Navigation not blocked.
|
|
94
127
|
// Rewind back to the new location.
|
|
95
|
-
|
|
96
|
-
|
|
128
|
+
ignoreLocationSubscriptionEvents(() => {
|
|
129
|
+
session.navigation.shift(payload.delta);
|
|
97
130
|
});
|
|
98
131
|
// Update the location.
|
|
99
132
|
next(action);
|
|
@@ -101,8 +134,8 @@ export default function createNavigationBlockerMiddleware(environment, {
|
|
|
101
134
|
});
|
|
102
135
|
} else if (result) {
|
|
103
136
|
// Prevent the navigation: rewind to the previous location.
|
|
104
|
-
|
|
105
|
-
|
|
137
|
+
ignoreLocationSubscriptionEvents(() => {
|
|
138
|
+
session.navigation.shift(-payload.delta);
|
|
106
139
|
});
|
|
107
140
|
} else {
|
|
108
141
|
// Update the location.
|
|
@@ -113,7 +146,7 @@ export default function createNavigationBlockerMiddleware(environment, {
|
|
|
113
146
|
|
|
114
147
|
// Remove any navigation blockers on `DISPOSE` event.
|
|
115
148
|
case ActionTypes.DISPOSE:
|
|
116
|
-
removeAllNavigationBlockers();
|
|
149
|
+
removeAllNavigationBlockers(session);
|
|
117
150
|
return next(action);
|
|
118
151
|
default:
|
|
119
152
|
return next(action);
|
|
@@ -3,7 +3,7 @@ import ActionTypes from '../ActionTypes';
|
|
|
3
3
|
// Creates a "middleware" that transforms action payload (location).
|
|
4
4
|
export default function createTransformLocationMiddleware({
|
|
5
5
|
transformInputLocation,
|
|
6
|
-
|
|
6
|
+
transformSubscriptionLocation
|
|
7
7
|
}) {
|
|
8
8
|
return function transformLocationMiddleware() {
|
|
9
9
|
return next => action => {
|
|
@@ -23,7 +23,7 @@ export default function createTransformLocationMiddleware({
|
|
|
23
23
|
case ActionTypes.UPDATE:
|
|
24
24
|
return next({
|
|
25
25
|
type,
|
|
26
|
-
payload:
|
|
26
|
+
payload: transformSubscriptionLocation(payload)
|
|
27
27
|
});
|
|
28
28
|
default:
|
|
29
29
|
return next(action);
|
|
@@ -1,59 +1,63 @@
|
|
|
1
|
+
/* eslint-disable no-underscore-dangle */
|
|
2
|
+
|
|
1
3
|
import isPromise from './isPromise';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
let removeBeforeDestroyListener;
|
|
5
|
-
export function getNavigationBlockers() {
|
|
6
|
-
return navigationBlockersList;
|
|
4
|
+
export function getNavigationBlockers(session) {
|
|
5
|
+
return session._navigationBlockersList || [];
|
|
7
6
|
}
|
|
8
|
-
function addNavigationBlockerToTheList(blocker) {
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
function addNavigationBlockerToTheList(blocker, session) {
|
|
8
|
+
if (!session._navigationBlockersList) {
|
|
9
|
+
session._navigationBlockersList = [];
|
|
10
|
+
}
|
|
11
|
+
session._navigationBlockersList.push(blocker);
|
|
11
12
|
}
|
|
12
|
-
function removeNavigationBlockerFromTheList(blocker) {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
function removeNavigationBlockerFromTheList(blocker, session) {
|
|
14
|
+
if (session._navigationBlockersList) {
|
|
15
|
+
session._navigationBlockersList = session._navigationBlockersList.filter(_ => _ !== blocker);
|
|
16
|
+
}
|
|
15
17
|
}
|
|
16
|
-
export function removeAllNavigationBlockers() {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
export function removeAllNavigationBlockers(session) {
|
|
19
|
+
if (getNavigationBlockers(session).some(blocker => blocker.beforeDestroy)) {
|
|
20
|
+
if (!session._removeBeforeDestroyListener) {
|
|
21
|
+
throw new Error('`_removeBeforeDestroyListener` property not found in the `session`');
|
|
22
|
+
}
|
|
23
|
+
session._removeBeforeDestroyListener();
|
|
24
|
+
session._removeBeforeDestroyListener = undefined;
|
|
21
25
|
}
|
|
22
|
-
|
|
26
|
+
session._navigationBlockersList = [];
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
// Runs the `
|
|
29
|
+
// Runs the `blocker` while ignoring any errors that might be thrown by it.
|
|
26
30
|
function runNavigationBlocker({
|
|
27
|
-
|
|
31
|
+
blocker
|
|
28
32
|
}, location) {
|
|
29
33
|
let result;
|
|
30
34
|
try {
|
|
31
|
-
result =
|
|
35
|
+
result = blocker(location);
|
|
32
36
|
} catch (error) {
|
|
33
37
|
// eslint-disable-next-line no-console
|
|
34
|
-
console.warn(`Ignoring navigation blocker \`${
|
|
38
|
+
console.warn(`Ignoring navigation blocker \`${blocker.name}\` that failed with \`${error}\`.`);
|
|
35
39
|
// eslint-disable-next-line no-console
|
|
36
40
|
console.error(error);
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
// If the
|
|
43
|
+
// If the blocker returned a `Promise`, await for that `Promise`
|
|
40
44
|
// and then return the result.
|
|
41
45
|
if (isPromise(result)) {
|
|
42
46
|
return result.catch(error => {
|
|
43
47
|
// eslint-disable-next-line no-console
|
|
44
|
-
console.warn(`Ignoring navigation blocker \`${
|
|
48
|
+
console.warn(`Ignoring navigation blocker \`${blocker.name}\` that failed with \`${error}\`.`);
|
|
45
49
|
// eslint-disable-next-line no-console
|
|
46
50
|
console.error(error);
|
|
47
51
|
});
|
|
48
52
|
}
|
|
49
|
-
// The
|
|
53
|
+
// The blocker didn't return a `Promise`.
|
|
50
54
|
// Return the "synchronous" result.
|
|
51
55
|
return result;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
// Runs all
|
|
55
|
-
// If any
|
|
56
|
-
// If there's no such
|
|
58
|
+
// Runs all blockers in order.
|
|
59
|
+
// If any blocker returns `true`, it stops and returns the result.
|
|
60
|
+
// If there's no such blocker, returns `undefined`.
|
|
57
61
|
export function runNavigationBlockers(navigationBlockers, toLocation) {
|
|
58
62
|
if (navigationBlockers.length === 0) {
|
|
59
63
|
return undefined;
|
|
@@ -80,17 +84,17 @@ export function runNavigationBlockers(navigationBlockers, toLocation) {
|
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
/* istanbul ignore next: not testable with Karma */
|
|
83
|
-
function onBeforeDestroy() {
|
|
84
|
-
const result = runNavigationBlockers(getNavigationBlockers(), null);
|
|
87
|
+
function onBeforeDestroy(session) {
|
|
88
|
+
const result = runNavigationBlockers(getNavigationBlockers(session), null);
|
|
85
89
|
|
|
86
|
-
// If no
|
|
90
|
+
// If no blocker returned anything, don't prevent the "unload" event.
|
|
87
91
|
if (!result) {
|
|
88
92
|
return undefined;
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
// Web browsers don't allow displaying a custom modal in "beforeunload" phase.
|
|
92
96
|
// They only allow displaying a standard one, with the default text.
|
|
93
|
-
// Hence, "asynchronous"
|
|
97
|
+
// Hence, "asynchronous" blockers should be ignored.
|
|
94
98
|
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
|
|
95
99
|
if (isPromise(result)) {
|
|
96
100
|
return undefined;
|
|
@@ -99,40 +103,44 @@ function onBeforeDestroy() {
|
|
|
99
103
|
// Prevent the "unload" event.
|
|
100
104
|
return true;
|
|
101
105
|
}
|
|
102
|
-
export function addNavigationBlocker(
|
|
103
|
-
onlyAllowedOnClientSide();
|
|
104
|
-
|
|
106
|
+
export function addNavigationBlocker(session, blocker) {
|
|
105
107
|
// All navigation blockers also run on `beforeDestroy` event.
|
|
106
108
|
// If required, this could be a parameter of this function.
|
|
107
|
-
// The rationale could be that adding `beforeunload`
|
|
109
|
+
// The rationale could be that adding a `beforeunload` listener
|
|
108
110
|
// disables web page caching in some browsers like Firefox.
|
|
109
111
|
const beforeDestroy = true;
|
|
110
112
|
|
|
111
|
-
// If it's the first "beforeDestroy"
|
|
113
|
+
// If it's the first "beforeDestroy" blocker, add the global `onBeforeDestroy` listener.
|
|
112
114
|
//
|
|
113
115
|
// Sidenote: Add the "beforeunload" event listener only as needed, as its presence
|
|
114
116
|
// prevents the page from being added to the page navigation cache:
|
|
115
117
|
//
|
|
116
118
|
// "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,
|
|
119
|
+
// that is, Firefox will not place pages in the bfcache if they have "beforeunload" listeners,
|
|
118
120
|
// and this is bad for performance."
|
|
119
121
|
//
|
|
120
122
|
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
|
|
121
|
-
if (beforeDestroy && !getNavigationBlockers().some(
|
|
122
|
-
|
|
123
|
+
if (beforeDestroy && !getNavigationBlockers(session).some(navigationBlocker => navigationBlocker.beforeDestroy)) {
|
|
124
|
+
if (session._removeBeforeDestroyListener) {
|
|
125
|
+
throw new Error('Unexpected `_removeBeforeDestroyListener` property found in the `session`');
|
|
126
|
+
}
|
|
127
|
+
session._removeBeforeDestroyListener = session.addBeforeDestroyListener(() => onBeforeDestroy(session));
|
|
123
128
|
}
|
|
124
|
-
const
|
|
125
|
-
|
|
129
|
+
const newNavigationBlocker = {
|
|
130
|
+
blocker,
|
|
126
131
|
beforeDestroy
|
|
127
132
|
};
|
|
128
|
-
addNavigationBlockerToTheList(
|
|
133
|
+
addNavigationBlockerToTheList(newNavigationBlocker, session);
|
|
129
134
|
return () => {
|
|
130
|
-
removeNavigationBlockerFromTheList(
|
|
135
|
+
removeNavigationBlockerFromTheList(newNavigationBlocker, session);
|
|
131
136
|
|
|
132
|
-
// If it was the last "beforeDestroy"
|
|
133
|
-
if (beforeDestroy && !getNavigationBlockers().some(navigationBlocker => navigationBlocker.beforeDestroy)) {
|
|
134
|
-
|
|
135
|
-
|
|
137
|
+
// If it was the last "beforeDestroy" blocker, remove the global `onBeforeDestroy` listener.
|
|
138
|
+
if (beforeDestroy && !getNavigationBlockers(session).some(navigationBlocker => navigationBlocker.beforeDestroy)) {
|
|
139
|
+
if (!session._removeBeforeDestroyListener) {
|
|
140
|
+
throw new Error('`_removeBeforeDestroyListener` property not found in the `session`');
|
|
141
|
+
}
|
|
142
|
+
session._removeBeforeDestroyListener();
|
|
143
|
+
session._removeBeforeDestroyListener = undefined;
|
|
136
144
|
}
|
|
137
145
|
};
|
|
138
146
|
}
|
|
@@ -35,6 +35,7 @@ export default function normalizeInputLocation(location) {
|
|
|
35
35
|
// Set default values on `search` and `hash`
|
|
36
36
|
// if those properties are not present.
|
|
37
37
|
return Object.assign({}, location, {
|
|
38
|
+
query: location.query || {},
|
|
38
39
|
search: location.search || '',
|
|
39
40
|
hash: location.hash || ''
|
|
40
41
|
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const _excluded = ["delta"];
|
|
2
|
+
function _objectWithoutPropertiesLoose(r, e) { if (null == r) return {}; var t = {}; for (var n in r) if ({}.hasOwnProperty.call(r, n)) { if (-1 !== e.indexOf(n)) continue; t[n] = r[n]; } return t; }
|
|
3
|
+
/* eslint-disable max-classes-per-file */
|
|
4
|
+
|
|
5
|
+
import getLocationUrl from '../getLocationUrl';
|
|
6
|
+
import parseQueryFromSearch from '../parseQueryFromSearch';
|
|
7
|
+
const INITIAL_KEY_INDEX = -1;
|
|
8
|
+
const INITIAL_INDEX = -1;
|
|
9
|
+
const INIT_LOCATION_DELTA = 0;
|
|
10
|
+
|
|
11
|
+
// A web browser has a notion of a "navigation history".
|
|
12
|
+
// A "navigation history" exists within a given web browser's tab.
|
|
13
|
+
// The user can click "Back" or "Forward" buttons in the web browser and it will automatically load
|
|
14
|
+
// "previous" or "next" page from scratch.
|
|
15
|
+
//
|
|
16
|
+
// Later, web browsers added a `window.history` object that the application can,
|
|
17
|
+
// but isn't required to, interact with. That `window.history` object allows the application
|
|
18
|
+
// to programmatically control the URL in the address bar of the web browser, as well as
|
|
19
|
+
// the "navigation history" by programmatically adding new entries to it or reading the current entry,
|
|
20
|
+
// and it also allows the application to override the default web browser's behavior
|
|
21
|
+
// when the user clicks "Back" or "Forward" buttons in the web browser.
|
|
22
|
+
//
|
|
23
|
+
// Specifically, the `window.history` object has a method called `.pushState()` which programmatically adds
|
|
24
|
+
// a new entry in the "navigation history" and updates the URL in the address bar and also
|
|
25
|
+
// tells the web browser that starting from the entry before this new entry in the "navigation history",
|
|
26
|
+
// the application would prefer to manually handle any "Back"/"Forward" transition when the user clicks
|
|
27
|
+
// those "Back" or "Forward" buttons in the web browser, and this behavior should persist for any future
|
|
28
|
+
// "navigation history" entries programmatically added by the application via `window.history.pushState()`,
|
|
29
|
+
// and will only stop if the user navigates from the page by the means of conventional navigation,
|
|
30
|
+
// that is by clicking a standard hyperlink, at which point the current page gets "destroyed".
|
|
31
|
+
//
|
|
32
|
+
// So for manually "pushed" entries of the "navigation history", the web browser won't load those pages
|
|
33
|
+
// from scratch after a user-initiated "Back" or "Forward" transition. In fact, it won't do anything and
|
|
34
|
+
// it will just step aside and let the application itself do those transitions. The web browser will only
|
|
35
|
+
// update the URL in the address bar and that's it.
|
|
36
|
+
//
|
|
37
|
+
// This whole thing allows the application to:
|
|
38
|
+
//
|
|
39
|
+
// * Load the "previous" or "next" page much faster than when using the default "from scratch" approach
|
|
40
|
+
// because it doesn't have to destroy the current page, then send a new HTTP request to the server,
|
|
41
|
+
// then parse the HTML response and initialize a new page, re-download all those images, etc.
|
|
42
|
+
//
|
|
43
|
+
// * Optionally render a snapshotted verison of the "previous" page thereby "restoring" the "previous" page
|
|
44
|
+
// rather than reloading it from scratch, i.e. the state of the "previous" page could be fully restored.
|
|
45
|
+
//
|
|
46
|
+
class BrowserNavigation {
|
|
47
|
+
constructor() {
|
|
48
|
+
// `this._keyPrefix` exists to avoid `this._keyIndex` collision after a page refresh.
|
|
49
|
+
// After a page refresh, `this._keyIndex` is reset to `0` while the previous navigation history
|
|
50
|
+
// still exists because web browser navigation history survives a page reload.
|
|
51
|
+
this._keyPrefix = Date.now().toString(36);
|
|
52
|
+
// `this._keyIndex` is incremented every time the current location changes.
|
|
53
|
+
this._keyIndex = INITIAL_KEY_INDEX;
|
|
54
|
+
|
|
55
|
+
// `this._index` is the index of the top element in the navigation stack.
|
|
56
|
+
// I.e. it's the index of the "current" location in the navigation stack.
|
|
57
|
+
this._index = INITIAL_INDEX;
|
|
58
|
+
}
|
|
59
|
+
init() {
|
|
60
|
+
return this._createEntryFromCurrentLocation();
|
|
61
|
+
}
|
|
62
|
+
_createEntryFromCurrentLocation() {
|
|
63
|
+
const {
|
|
64
|
+
pathname,
|
|
65
|
+
search,
|
|
66
|
+
hash
|
|
67
|
+
} = window.location;
|
|
68
|
+
const isSettingInitialLocation = this._index === INITIAL_INDEX;
|
|
69
|
+
const {
|
|
70
|
+
key,
|
|
71
|
+
index,
|
|
72
|
+
delta,
|
|
73
|
+
state
|
|
74
|
+
} = isSettingInitialLocation ? this._createAdditionalPropertiesForNewLocation({
|
|
75
|
+
delta: 1,
|
|
76
|
+
state: undefined
|
|
77
|
+
}) : this._restoreAdditionalPropertiesForCurrentLocation();
|
|
78
|
+
return {
|
|
79
|
+
action: isSettingInitialLocation ? 'INIT' : 'POP',
|
|
80
|
+
pathname,
|
|
81
|
+
search,
|
|
82
|
+
query: parseQueryFromSearch(search),
|
|
83
|
+
hash,
|
|
84
|
+
key,
|
|
85
|
+
index,
|
|
86
|
+
delta: isSettingInitialLocation ? INIT_LOCATION_DELTA : delta,
|
|
87
|
+
state
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Subscribes to changes in location,
|
|
92
|
+
// excluding ones that happened as a result of calling `.navigate()`.
|
|
93
|
+
subscribe(listener) {
|
|
94
|
+
const onPopState = () => {
|
|
95
|
+
listener(this._createEntryFromCurrentLocation());
|
|
96
|
+
};
|
|
97
|
+
window.addEventListener('popstate', onPopState);
|
|
98
|
+
return () => {
|
|
99
|
+
window.removeEventListener('popstate', onPopState);
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
navigate(location) {
|
|
103
|
+
const {
|
|
104
|
+
action,
|
|
105
|
+
state
|
|
106
|
+
} = location;
|
|
107
|
+
if (action !== 'PUSH' && action !== 'REPLACE') {
|
|
108
|
+
throw Error(`Unrecognized browser session action: ${action}`);
|
|
109
|
+
}
|
|
110
|
+
if (this._index === INITIAL_INDEX) {
|
|
111
|
+
throw Error('Browser session must be initialized before navigation');
|
|
112
|
+
}
|
|
113
|
+
const delta = action === 'PUSH' ? 1 : 0;
|
|
114
|
+
const additionalProperties = this._createAdditionalPropertiesForNewLocation({
|
|
115
|
+
delta,
|
|
116
|
+
state
|
|
117
|
+
});
|
|
118
|
+
this._storeAdditionalPropertiesForLocation(location, additionalProperties);
|
|
119
|
+
return Object.assign({}, location, additionalProperties);
|
|
120
|
+
}
|
|
121
|
+
shift(delta) {
|
|
122
|
+
window.history.go(delta);
|
|
123
|
+
}
|
|
124
|
+
_createKeyForKeyIndex(keyIndex) {
|
|
125
|
+
return `${this._keyPrefix}.${keyIndex.toString(36)}`;
|
|
126
|
+
}
|
|
127
|
+
_createAdditionalPropertiesForNewLocation({
|
|
128
|
+
delta,
|
|
129
|
+
state
|
|
130
|
+
}) {
|
|
131
|
+
this._keyIndex++;
|
|
132
|
+
this._index += delta;
|
|
133
|
+
return {
|
|
134
|
+
key: this._createKeyForKeyIndex(this._keyIndex),
|
|
135
|
+
index: this._index,
|
|
136
|
+
delta,
|
|
137
|
+
state
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
_restoreAdditionalPropertiesForCurrentLocation() {
|
|
141
|
+
// Initial location doesn't have any `window.history.state` assigned to it
|
|
142
|
+
// because it wasn't navigated to via a `window.history.pushState()` method.
|
|
143
|
+
// Because of that, the additional properties for the initial location can't be read
|
|
144
|
+
// from `window.history.state` and have to be reconstructed manually.
|
|
145
|
+
const {
|
|
146
|
+
key,
|
|
147
|
+
index,
|
|
148
|
+
state
|
|
149
|
+
} = window.history.state || this._getAdditionalPropertiesForInitialLocation();
|
|
150
|
+
const delta = index - this._index;
|
|
151
|
+
this._index = index;
|
|
152
|
+
return {
|
|
153
|
+
key,
|
|
154
|
+
index,
|
|
155
|
+
delta,
|
|
156
|
+
state
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
_storeAdditionalPropertiesForLocation(location, additionalProperties) {
|
|
160
|
+
const url = getLocationUrl(location);
|
|
161
|
+
// `delta` property is not stored in `window.history.state`
|
|
162
|
+
// because it is supposed to be recalculated every time when reading from `window.history.state`.
|
|
163
|
+
const {
|
|
164
|
+
delta
|
|
165
|
+
} = additionalProperties,
|
|
166
|
+
restProperties = _objectWithoutPropertiesLoose(additionalProperties, _excluded);
|
|
167
|
+
if (delta === 1) {
|
|
168
|
+
window.history.pushState(restProperties, null, url);
|
|
169
|
+
} else if (delta === 0) {
|
|
170
|
+
window.history.replaceState(restProperties, null, url);
|
|
171
|
+
} else {
|
|
172
|
+
throw new Error(`Unexpected \`delta\` when storing additional properties for location: ${delta}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Initial location doesn't have any `window.history.state` assigned to it
|
|
177
|
+
// because it wasn't navigated to via a `window.history.pushState()` method.
|
|
178
|
+
// Because of that, the additional properties for the initial location can't be read
|
|
179
|
+
// from `window.history.state` and have to be reconstructed manually.
|
|
180
|
+
_getAdditionalPropertiesForInitialLocation() {
|
|
181
|
+
return {
|
|
182
|
+
key: this._createKeyForKeyIndex(INITIAL_KEY_INDEX + 1),
|
|
183
|
+
index: INITIAL_INDEX + 1,
|
|
184
|
+
delta: INIT_LOCATION_DELTA,
|
|
185
|
+
state: undefined
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
class BrowserDataStorage {
|
|
190
|
+
// Returns either a `string` value or `null` if the key doesn't exist.
|
|
191
|
+
get(key) {
|
|
192
|
+
// `sessionStorage` persists across page reloads, and so does web browser navigation history.
|
|
193
|
+
return window.sessionStorage.getItem(key);
|
|
194
|
+
}
|
|
195
|
+
remove(key) {
|
|
196
|
+
// `sessionStorage` persists across page reloads, and so does web browser navigation history.
|
|
197
|
+
window.sessionStorage.removeItem(key);
|
|
198
|
+
}
|
|
199
|
+
set(key, value) {
|
|
200
|
+
// `sessionStorage` persists across page reloads, and so does web browser navigation history.
|
|
201
|
+
window.sessionStorage.setItem(key, value);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export default class BrowserSession {
|
|
205
|
+
constructor() {
|
|
206
|
+
this.navigation = new BrowserNavigation();
|
|
207
|
+
this.dataStorage = new BrowserDataStorage();
|
|
208
|
+
}
|
|
209
|
+
addBeforeDestroyListener(onBeforeDestroy) {
|
|
210
|
+
const onBeforeUnload = event => {
|
|
211
|
+
if (onBeforeDestroy()) {
|
|
212
|
+
// Calling `event.preventDefault()` will cause a web browser
|
|
213
|
+
// to show a generic "Ok"/"Cancel" modal with some generic text:
|
|
214
|
+
// "Are you sure to leave the current page?".
|
|
215
|
+
event.preventDefault();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
219
|
+
return () => {
|
|
220
|
+
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|