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