navigation-stack 0.5.3 → 0.6.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 (210) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +144 -282
  3. package/karma.conf.cjs +1 -1
  4. package/lib/cjs/NavigationStack.js +138 -49
  5. package/lib/cjs/data-storage/DataStorage.js +7 -6
  6. package/lib/cjs/environment/InMemoryEnvironment.js +6 -0
  7. package/lib/cjs/{session/ServerSideRenderSession.js → environment/ServerSideRenderEnvironment.js} +5 -6
  8. package/lib/cjs/environment/WebBrowserEnvironment.js +6 -0
  9. package/lib/cjs/environment/log/InMemoryLog.js +23 -0
  10. package/lib/cjs/environment/log/WebBrowserLog.js +22 -0
  11. package/lib/cjs/{session → environment}/navigation/InMemoryNavigation.js +16 -5
  12. package/lib/cjs/{session → environment}/navigation/ServerSideNavigation.js +16 -7
  13. package/lib/cjs/{session → environment}/navigation/WebBrowserNavigation.js +48 -8
  14. package/lib/cjs/{session/navigation/error/ServerSideNavigationError.js → environment/navigation/error/ServerSideRedirectError.js} +2 -2
  15. package/lib/cjs/environment/scroll-position/WebBrowserScrollPosition.js +15 -0
  16. package/lib/cjs/getLocationBaseFromLocation.js +14 -0
  17. package/lib/cjs/getLocationUrl.js +3 -5
  18. package/lib/cjs/index.js +10 -16
  19. package/lib/cjs/navigationBlockers.js +34 -32
  20. package/lib/cjs/navigationBlockersEvaluation.js +150 -0
  21. package/lib/cjs/parseInputLocation.js +2 -2
  22. package/lib/cjs/parseQueryFromSearch.js +3 -6
  23. package/lib/cjs/parseQueryString.js +77 -0
  24. package/lib/cjs/scroll-position/ScrollPositionAutoSaver.js +7 -6
  25. package/lib/cjs/scroll-position/ScrollPositionRestoration.js +31 -27
  26. package/lib/cjs/scroll-position/ScrollPositionSaver.js +6 -4
  27. package/lib/cjs/session/Session.js +61 -26
  28. package/lib/cjs/session/subscription/Subscription.js +36 -18
  29. package/lib/cjs/stringifyQuery.js +66 -0
  30. package/lib/cjs/stringifyQueryAsSearch.js +14 -0
  31. package/lib/esm/NavigationStack.js +138 -49
  32. package/lib/esm/data-storage/DataStorage.js +7 -6
  33. package/lib/esm/environment/InMemoryEnvironment.js +6 -0
  34. package/lib/esm/environment/ServerSideRenderEnvironment.js +10 -0
  35. package/lib/esm/environment/WebBrowserEnvironment.js +6 -0
  36. package/lib/esm/environment/log/InMemoryLog.js +17 -0
  37. package/lib/esm/environment/log/WebBrowserLog.js +16 -0
  38. package/lib/esm/{session → environment}/navigation/InMemoryNavigation.js +16 -5
  39. package/lib/esm/{session → environment}/navigation/ServerSideNavigation.js +16 -7
  40. package/lib/esm/{session → environment}/navigation/WebBrowserNavigation.js +48 -8
  41. package/lib/esm/{session/navigation/error/ServerSideNavigationError.js → environment/navigation/error/ServerSideRedirectError.js} +1 -1
  42. package/lib/esm/environment/scroll-position/WebBrowserScrollPosition.js +15 -0
  43. package/lib/esm/getLocationBaseFromLocation.js +9 -0
  44. package/lib/esm/getLocationUrl.js +2 -5
  45. package/lib/esm/index.js +5 -8
  46. package/lib/esm/navigationBlockers.js +34 -32
  47. package/lib/esm/navigationBlockersEvaluation.js +145 -0
  48. package/lib/esm/parseInputLocation.js +2 -2
  49. package/lib/esm/parseQueryFromSearch.js +2 -6
  50. package/lib/esm/parseQueryString.js +72 -0
  51. package/lib/esm/scroll-position/ScrollPositionAutoSaver.js +7 -6
  52. package/lib/esm/scroll-position/ScrollPositionRestoration.js +31 -27
  53. package/lib/esm/scroll-position/ScrollPositionSaver.js +6 -4
  54. package/lib/esm/session/Session.js +61 -26
  55. package/lib/esm/session/subscription/Subscription.js +36 -18
  56. package/lib/esm/stringifyQuery.js +61 -0
  57. package/lib/esm/stringifyQueryAsSearch.js +8 -0
  58. package/lib/index.d.ts +180 -34
  59. package/package.json +4 -7
  60. package/src/NavigationStack.js +166 -56
  61. package/src/data-storage/DataStorage.js +9 -6
  62. package/src/environment/InMemoryEnvironment.js +6 -0
  63. package/src/environment/ServerSideRenderEnvironment.js +10 -0
  64. package/src/environment/WebBrowserEnvironment.js +6 -0
  65. package/src/environment/log/InMemoryLog.js +20 -0
  66. package/src/environment/log/WebBrowserLog.js +18 -0
  67. package/src/{session → environment}/navigation/InMemoryNavigation.js +16 -5
  68. package/src/{session → environment}/navigation/ServerSideNavigation.js +16 -7
  69. package/src/{session → environment}/navigation/WebBrowserNavigation.js +48 -8
  70. package/src/{session/navigation/error/ServerSideNavigationError.js → environment/navigation/error/ServerSideRedirectError.js} +1 -1
  71. package/src/environment/scroll-position/WebBrowserScrollPosition.js +15 -0
  72. package/src/getLocationBaseFromLocation.js +7 -0
  73. package/src/getLocationUrl.js +2 -5
  74. package/src/index.js +10 -13
  75. package/src/navigationBlockers.js +55 -34
  76. package/src/navigationBlockersEvaluation.js +161 -0
  77. package/src/parseInputLocation.js +2 -2
  78. package/src/parseQueryFromSearch.js +2 -6
  79. package/src/parseQueryString.js +81 -0
  80. package/src/scroll-position/ScrollPositionAutoSaver.js +10 -6
  81. package/src/scroll-position/ScrollPositionRestoration.js +36 -30
  82. package/src/scroll-position/ScrollPositionSaver.js +6 -4
  83. package/src/scroll-position/index.js +1 -1
  84. package/src/session/Session.js +68 -24
  85. package/src/session/subscription/Subscription.js +36 -11
  86. package/src/stringifyQuery.js +71 -0
  87. package/src/stringifyQueryAsSearch.js +9 -0
  88. package/test/NavigationStack.addBasePath.test.js +50 -0
  89. package/test/{redux/middleware/createNonProgrammaticNavigationBlockerMiddleware.test.js → NavigationStack.blockNonProgrammaticNavigationIfRequired.test.js} +51 -63
  90. package/test/{redux/middleware/createProgrammaticNavigationBlockerMiddleware.test.js → NavigationStack.blockProgrammaticNavigationIfRequired.test.js} +98 -78
  91. package/test/NavigationStack.general.test.js +68 -0
  92. package/test/NavigationStack.parseInputLocation.test.js +52 -0
  93. package/test/NavigationStack.removeBasePath.test.js +69 -0
  94. package/test/NavigationStack.test.js +97 -29
  95. package/test/data-storage/LocationDataStorage.test.js +3 -2
  96. package/test/index.js +7 -31
  97. package/test/index.test.js +4 -5
  98. package/test/parseQueryFromSearch.test.js +19 -0
  99. package/test/parseQueryString.test.js +18 -0
  100. package/test/scroll-position/ScrollPositionRestoration.test.js +34 -13
  101. package/test/scroll-position/createApp.js +8 -8
  102. package/test/scroll-position/withScrollableContainerAtIndexPageWithDisabledAutomaticScrollPositionRestoration.js +4 -4
  103. package/test/session/{InMemorySession.test.js → Session.InMemoryEnvironment.test.js} +10 -9
  104. package/test/session/{ServerSession.test.js → Session.ServerSideRenderEnvironment.test.js} +5 -4
  105. package/test/session/{WebBrowserSession.test.js → Session.WebBrowserEnvironment.test.js} +63 -13
  106. package/test/shouldWarn.js +44 -0
  107. package/test/stringifyQuery.test.js +65 -0
  108. package/types/index.d.ts +180 -34
  109. package/types/tsconfig.json +0 -1
  110. package/data-storage/package.json +0 -7
  111. package/lib/cjs/createSearchFromQuery.js +0 -13
  112. package/lib/cjs/debug.js +0 -12
  113. package/lib/cjs/redux/ActionTypes.js +0 -14
  114. package/lib/cjs/redux/ActionTypesInternal.js +0 -8
  115. package/lib/cjs/redux/Actions.js +0 -28
  116. package/lib/cjs/redux/createMiddlewares.js +0 -60
  117. package/lib/cjs/redux/index.js +0 -13
  118. package/lib/cjs/redux/internalLocationReducer.js +0 -14
  119. package/lib/cjs/redux/locationReducer.js +0 -13
  120. package/lib/cjs/redux/middleware/createAddInputLocationBasePathMiddleware.js +0 -32
  121. package/lib/cjs/redux/middleware/createNonProgrammaticNavigationBlockerMiddleware.js +0 -113
  122. package/lib/cjs/redux/middleware/createProgrammaticNavigationBlockerMiddleware.js +0 -94
  123. package/lib/cjs/redux/middleware/createRemoveOutputLocationBasePathMiddleware.js +0 -30
  124. package/lib/cjs/redux/middleware/createUpdateInternalLocationMiddleware.js +0 -73
  125. package/lib/cjs/redux/middleware/navigationOperationMiddleware.js +0 -40
  126. package/lib/cjs/redux/middleware/parseInputLocationMiddleware.js +0 -29
  127. package/lib/cjs/redux/middleware/updateLocationMiddleware.js +0 -34
  128. package/lib/cjs/session/InMemorySession.js +0 -22
  129. package/lib/cjs/session/WebBrowserSession.js +0 -20
  130. package/lib/data-storage/index.d.ts +0 -35
  131. package/lib/esm/createSearchFromQuery.js +0 -8
  132. package/lib/esm/debug.js +0 -7
  133. package/lib/esm/redux/ActionTypes.js +0 -9
  134. package/lib/esm/redux/ActionTypesInternal.js +0 -3
  135. package/lib/esm/redux/Actions.js +0 -22
  136. package/lib/esm/redux/createMiddlewares.js +0 -54
  137. package/lib/esm/redux/index.js +0 -4
  138. package/lib/esm/redux/internalLocationReducer.js +0 -8
  139. package/lib/esm/redux/locationReducer.js +0 -7
  140. package/lib/esm/redux/middleware/createAddInputLocationBasePathMiddleware.js +0 -27
  141. package/lib/esm/redux/middleware/createNonProgrammaticNavigationBlockerMiddleware.js +0 -108
  142. package/lib/esm/redux/middleware/createProgrammaticNavigationBlockerMiddleware.js +0 -88
  143. package/lib/esm/redux/middleware/createRemoveOutputLocationBasePathMiddleware.js +0 -25
  144. package/lib/esm/redux/middleware/createUpdateInternalLocationMiddleware.js +0 -68
  145. package/lib/esm/redux/middleware/navigationOperationMiddleware.js +0 -35
  146. package/lib/esm/redux/middleware/parseInputLocationMiddleware.js +0 -24
  147. package/lib/esm/redux/middleware/updateLocationMiddleware.js +0 -28
  148. package/lib/esm/session/InMemorySession.js +0 -15
  149. package/lib/esm/session/ServerSideRenderSession.js +0 -11
  150. package/lib/esm/session/WebBrowserSession.js +0 -13
  151. package/lib/redux/index.d.ts +0 -90
  152. package/lib/scroll-position/index.d.ts +0 -107
  153. package/redux/package.json +0 -7
  154. package/scroll-position/package.json +0 -7
  155. package/src/createSearchFromQuery.js +0 -9
  156. package/src/debug.js +0 -8
  157. package/src/redux/ActionTypes.js +0 -9
  158. package/src/redux/ActionTypesInternal.js +0 -3
  159. package/src/redux/Actions.js +0 -27
  160. package/src/redux/createMiddlewares.js +0 -65
  161. package/src/redux/index.js +0 -4
  162. package/src/redux/internalLocationReducer.js +0 -9
  163. package/src/redux/locationReducer.js +0 -8
  164. package/src/redux/middleware/createAddInputLocationBasePathMiddleware.js +0 -27
  165. package/src/redux/middleware/createNonProgrammaticNavigationBlockerMiddleware.js +0 -119
  166. package/src/redux/middleware/createProgrammaticNavigationBlockerMiddleware.js +0 -94
  167. package/src/redux/middleware/createRemoveOutputLocationBasePathMiddleware.js +0 -26
  168. package/src/redux/middleware/createUpdateInternalLocationMiddleware.js +0 -72
  169. package/src/redux/middleware/navigationOperationMiddleware.js +0 -34
  170. package/src/redux/middleware/parseInputLocationMiddleware.js +0 -23
  171. package/src/redux/middleware/updateLocationMiddleware.js +0 -28
  172. package/src/session/InMemorySession.js +0 -13
  173. package/src/session/ServerSideRenderSession.js +0 -9
  174. package/src/session/WebBrowserSession.js +0 -13
  175. package/test/middlewareTestUtil.js +0 -31
  176. package/test/redux/Action.test.js +0 -73
  177. package/test/redux/ActionTypes.test.js +0 -13
  178. package/test/redux/createMiddlewares.test.js +0 -96
  179. package/test/redux/index.test.js +0 -10
  180. package/test/redux/locationReducer.test.js +0 -39
  181. package/test/redux/middleware/createAddInputLocationBasePathMiddleware.test.js +0 -40
  182. package/test/redux/middleware/createRemoveOutputLocationBasePathMiddleware.test.js +0 -51
  183. package/test/redux/middleware/navigationOperationMiddleware.test.js +0 -78
  184. package/test/redux/middleware/parseInputLocationMiddleware.test.js +0 -62
  185. package/test/testUtil.js +0 -3
  186. package/types/data-storage/index.d.ts +0 -35
  187. package/types/redux/index.d.ts +0 -90
  188. package/types/scroll-position/index.d.ts +0 -107
  189. /package/lib/cjs/{session → environment}/lifecycle/InMemorySessionLifecycle.js +0 -0
  190. /package/lib/cjs/{session → environment}/lifecycle/WebBrowserSessionLifecycle.js +0 -0
  191. /package/lib/cjs/{session → environment}/lifecycle/page-lifecycle/PageLifecycle.js +0 -0
  192. /package/lib/cjs/{session → environment}/lifecycle/page-lifecycle/PageLifecycleInstance.js +0 -0
  193. /package/lib/cjs/{session → environment}/lifecycle/page-lifecycle/supportsConstructableEventTarget.js +0 -0
  194. /package/lib/cjs/{session → environment}/navigation/error/NavigationOutOfBoundsError.js +0 -0
  195. /package/lib/cjs/{session → environment}/navigation/operation/operations.js +0 -0
  196. /package/lib/esm/{session → environment}/lifecycle/InMemorySessionLifecycle.js +0 -0
  197. /package/lib/esm/{session → environment}/lifecycle/WebBrowserSessionLifecycle.js +0 -0
  198. /package/lib/esm/{session → environment}/lifecycle/page-lifecycle/PageLifecycle.js +0 -0
  199. /package/lib/esm/{session → environment}/lifecycle/page-lifecycle/PageLifecycleInstance.js +0 -0
  200. /package/lib/esm/{session → environment}/lifecycle/page-lifecycle/supportsConstructableEventTarget.js +0 -0
  201. /package/lib/esm/{session → environment}/navigation/error/NavigationOutOfBoundsError.js +0 -0
  202. /package/lib/esm/{session → environment}/navigation/operation/operations.js +0 -0
  203. /package/src/{session → environment}/lifecycle/InMemorySessionLifecycle.js +0 -0
  204. /package/src/{session → environment}/lifecycle/WebBrowserSessionLifecycle.js +0 -0
  205. /package/src/{session → environment}/lifecycle/page-lifecycle/PageLifecycle.js +0 -0
  206. /package/src/{session → environment}/lifecycle/page-lifecycle/PageLifecycleInstance.js +0 -0
  207. /package/src/{session → environment}/lifecycle/page-lifecycle/supportsConstructableEventTarget.js +0 -0
  208. /package/src/{session → environment}/navigation/error/NavigationOutOfBoundsError.js +0 -0
  209. /package/src/{session → environment}/navigation/operation/operations.js +0 -0
  210. /package/test/{parseInputLocationMiddleware.test.js → parseInputLocation.test.js} +0 -0
@@ -1,9 +1,8 @@
1
- import debug from '../debug';
2
1
  import parseInputLocation from '../parseInputLocation';
3
2
  import createSessionKey from './key/createSessionKey';
4
- import NavigationOutOfBoundsError from './navigation/error/NavigationOutOfBoundsError';
5
- import NavigationOperations from './navigation/operation/operations';
6
3
  import Subscription from './subscription/Subscription';
4
+ import NavigationOutOfBoundsError from '../environment/navigation/error/NavigationOutOfBoundsError';
5
+ import NavigationOperations from '../environment/navigation/operation/operations';
7
6
 
8
7
  const INITIAL_KEY_INDEX = -1;
9
8
  const INITIAL_INDEX = -1;
@@ -11,12 +10,15 @@ const INITIAL_INDEX = -1;
11
10
  const INIT_LOCATION_DELTA = 0;
12
11
 
13
12
  export default class Session {
14
- constructor({ navigation }) {
13
+ constructor(EnvironmentClass) {
15
14
  // `key` is used in `WebBrowserSession` to uniquely identify a session
16
15
  // when storing data in a `WebBrowserDataStorage` which uses `window.sessionStorage`
17
16
  // under the hood, and `window.sessionStorage` is shared between different sessions.
18
17
  this.key = createSessionKey();
19
18
 
19
+ // Create an environment instance.
20
+ this.environment = new EnvironmentClass();
21
+
20
22
  // `this._locationKeyIndex` is incremented every time the current location changes.
21
23
  this._locationKeyIndex = INITIAL_KEY_INDEX;
22
24
 
@@ -28,18 +30,36 @@ export default class Session {
28
30
  // In other words, this is the last location index that it can `.shift()` to.
29
31
  this._terminalLocationIndex = this._currentLocationIndex;
30
32
 
31
- // Create `navigation`.
32
- this._navigation = navigation;
33
+ // Allows subscribing to location updates.
34
+ this._subscription = new Subscription();
33
35
 
34
- // Manages subscriptions.
35
- this._subscription = new Subscription({
36
- activateSubscription: (listener) => {
37
- return this._navigation.subscribe(listener);
38
- },
36
+ // Subscribing to location changes means subscribing to both "synchronous"
37
+ // and "asynchronous" location changes. "synchronous" location changes
38
+ // happen immediately when the code triggers them."asynchronous" location changes
39
+ // either happen after an arbitrary delay or are even triggered from outside the code.
40
+ //
41
+ // Subscribing to "asynchronous" location changes is not necessary when
42
+ // there're no actual subscribers, in order to not unnecessarily "waste" any resources.
43
+ // Of course, this statement is rather far-fetched and in reality no one would ever tell any difference.
44
+ // Still, I felt like randomly introducing this seemingly unnecessary minor optimization.
45
+ //
46
+ // So it only subscribes to "asynchronous" location changes if there's at least one active subscriber.
47
+ // And in case all subscribers get unsubscribed, it will unsubscribe from "asynchronous" location changes too.
48
+ // One might think of it as some form of "mental masturbation", but what can I do — I already wrote the code.
49
+ //
50
+ this._subscription.onFirstSubscriber(() => {
51
+ return this.environment.navigation.subscribeToAsyncrhonousLocationUpdates(
52
+ (location) => {
53
+ // Notify all subscribers about this "asynchronous" location change.
54
+ this._subscription.notifySubscribers(location);
55
+ },
56
+ );
39
57
  });
40
58
 
41
- // Update current location index when a location change was not initiated
42
- // by this session but rather by the user clicking "Back" or "Forward" button.
59
+ // This subscription is triggered in two cases:
60
+ // * Set initial current location index at initial page load.
61
+ // * Update current location index whenever a location change is not initiated
62
+ // by this session but rather by the user clicking "Back" or "Forward" button.
43
63
  this._unsubscribe = this.subscribe((location) => {
44
64
  // Update `this._currentLocationIndex` when the location change was not initiated
45
65
  // by this session but rather by the user clicking "Back" or "Forward" button.
@@ -50,7 +70,7 @@ export default class Session {
50
70
  // by `navigation` to call `session.getNextKey()` function to increment `locationKeyIndex`.
51
71
  this._updateTerminalLocationIndex(location);
52
72
 
53
- debug(
73
+ this.environment.log.debug(
54
74
  'current location',
55
75
  location.pathname,
56
76
  'index',
@@ -60,14 +80,18 @@ export default class Session {
60
80
  }
61
81
 
62
82
  // Subscribes to changes in location.
83
+ // The first subscriber is always the `Session` itself:
84
+ // its listener keeps the current location index up-to-date.
85
+ // Any additional application-specific listeners could be added, if required.
86
+ // Applications should prefer adding any such listeners by calling `NavigationStack.subscribe()`
87
+ // method instead of calling this method directly, in order to "normalize" the `location` argument.
63
88
  subscribe(listener) {
64
89
  return this._subscription.subscribe((location) => {
65
90
  if (
66
91
  !this._isStarted() &&
67
92
  location.operation !== NavigationOperations.INIT
68
93
  ) {
69
- // eslint-disable-next-line no-console
70
- console.error('Unexpected location change', location);
94
+ this.environment.log.error('Unexpected location change', location);
71
95
  throw new Error('Not started');
72
96
  } else {
73
97
  // Call the listener.
@@ -76,6 +100,17 @@ export default class Session {
76
100
  });
77
101
  }
78
102
 
103
+ // Starts a navigation session.
104
+ //
105
+ // When run in a web browser, it could not only "start" a new session
106
+ // but also "resume" a previously-started session. That could happen
107
+ // when the user refreshes a page in a web browser which still retains
108
+ // the previous session's data but at the same time restarts the javascript code
109
+ // from scratch.
110
+ //
111
+ // So this `start()` method handles both cases: when there's previous session's data
112
+ // that should be restored and when there's no previous session's data.
113
+ //
79
114
  start(initialLocation) {
80
115
  if (this._stopped) {
81
116
  throw new Error('Can not be restarted');
@@ -88,7 +123,7 @@ export default class Session {
88
123
  // the initial location by the time javascript code starts execution.
89
124
  //
90
125
  if (!initialLocation) {
91
- initialLocation = this._navigation.getInitialLocation();
126
+ initialLocation = this.environment.navigation.getInitialLocation();
92
127
  if (initialLocation) {
93
128
  initialLocation = parseInputLocation(initialLocation);
94
129
  }
@@ -102,7 +137,7 @@ export default class Session {
102
137
  throw new Error('Already started');
103
138
  }
104
139
 
105
- debug('▶ start session', initialLocation.pathname);
140
+ this.environment.log.debug('▶ start session', initialLocation.pathname);
106
141
 
107
142
  this._started = true;
108
143
 
@@ -110,7 +145,7 @@ export default class Session {
110
145
  const index = INITIAL_INDEX + 1;
111
146
  const delta = INIT_LOCATION_DELTA;
112
147
 
113
- const locationResult = this._navigation.init(initialLocation, {
148
+ const locationResult = this.environment.navigation.init(initialLocation, {
114
149
  operation: NavigationOperations.INIT,
115
150
  key,
116
151
  index,
@@ -118,6 +153,7 @@ export default class Session {
118
153
  });
119
154
 
120
155
  if (locationResult) {
156
+ // Notify all subscribers about this "synchronous" location change.
121
157
  this._subscription.notifySubscribers(locationResult);
122
158
  }
123
159
  }
@@ -127,7 +163,7 @@ export default class Session {
127
163
  throw Error('Already stopped');
128
164
  }
129
165
 
130
- debug('⏹ stop session');
166
+ this.environment.log.debug('⏹ stop session');
131
167
 
132
168
  // Once stopped, it won't be able to be restarted.
133
169
  this._stopped = true;
@@ -160,7 +196,7 @@ export default class Session {
160
196
  const key = this._getNextLocationKey();
161
197
  const index = this._currentLocationIndex + delta;
162
198
 
163
- debug(
199
+ this.environment.log.debug(
164
200
  operation === NavigationOperations.PUSH ? '↓' : '⇅',
165
201
  operation,
166
202
  location.pathname,
@@ -169,7 +205,7 @@ export default class Session {
169
205
  );
170
206
 
171
207
  // Navigate to the location.
172
- const locationResult = this._navigation.navigate(location, {
208
+ const locationResult = this.environment.navigation.navigate(location, {
173
209
  operation,
174
210
  key,
175
211
  index,
@@ -177,6 +213,7 @@ export default class Session {
177
213
  });
178
214
 
179
215
  if (locationResult) {
216
+ // Notify all subscribers about this "synchronous" location change.
180
217
  this._subscription.notifySubscribers(locationResult);
181
218
  }
182
219
  }
@@ -193,7 +230,13 @@ export default class Session {
193
230
 
194
231
  const index = this._currentLocationIndex + delta;
195
232
 
196
- debug(delta > 0 ? '→' : '←', 'shift', delta, 'index', index);
233
+ this.environment.log.debug(
234
+ delta > 0 ? '→' : '←',
235
+ 'shift',
236
+ delta,
237
+ 'index',
238
+ index,
239
+ );
197
240
 
198
241
  // Validate that the new `index` is not out of bounds.
199
242
  if (index < 0 || index > this._terminalLocationIndex) {
@@ -201,13 +244,14 @@ export default class Session {
201
244
  }
202
245
 
203
246
  // Navigate to the location.
204
- const locationResult = this._navigation.shift({
247
+ const locationResult = this.environment.navigation.shift({
205
248
  operation: NavigationOperations.SHIFT,
206
249
  index,
207
250
  delta,
208
251
  });
209
252
 
210
253
  if (locationResult) {
254
+ // Notify all subscribers about this "synchronous" location change.
211
255
  this._subscription.notifySubscribers(locationResult);
212
256
  }
213
257
  }
@@ -1,18 +1,35 @@
1
1
  export default class Subscription {
2
- constructor({ activateSubscription } = {}) {
3
- this._activateSubscription = activateSubscription;
4
-
2
+ constructor() {
5
3
  // This property is accessed in tests.
6
4
  this._listeners = [];
5
+
6
+ // These listeners will be called when the subscription enters "active" or "inactive" state.
7
+ // A subscription enters "active" state when it has at least one listener rather than zero.
8
+ // A subscription enters "inactive" state when it has no more listeners.
9
+ this._subscriptionActiveStateListeners = [];
10
+ this._subscriptionInactiveStateListeners = [];
7
11
  }
8
12
 
9
- notifySubscribers = (argument) => {
13
+ // Adds a subscription active state listener.
14
+ // Returns a function that removes the subscription active state listener.
15
+ onFirstSubscriber(activeStateListener) {
16
+ this._subscriptionActiveStateListeners.push(activeStateListener);
17
+ // Return a function that removes the subscription active state listener.
18
+ return () => {
19
+ this._subscriptionActiveStateListeners =
20
+ this._subscriptionActiveStateListeners.filter(
21
+ (_) => _ !== activeStateListener,
22
+ );
23
+ };
24
+ }
25
+
26
+ notifySubscribers(argument) {
10
27
  // `._latest` is only used in tests.
11
28
  this._latest = argument;
12
29
  for (const { listener } of this._listeners) {
13
30
  listener(argument);
14
31
  }
15
- };
32
+ }
16
33
 
17
34
  subscribe(listener) {
18
35
  // If subscriptions are stopped, i.e. no new subscriptions are to be added,
@@ -30,9 +47,12 @@ export default class Subscription {
30
47
 
31
48
  // If it's the first listener, activate subscription.
32
49
  if (this._listeners.length === 0) {
33
- this._deactivateSubscription = this._activateSubscription(
34
- this.notifySubscribers,
35
- );
50
+ // Run all subscription active state listeners.
51
+ // The functions returned from those will become subscription inactive state listeners.
52
+ this._subscriptionInactiveStateListeners =
53
+ this._subscriptionActiveStateListeners.map((activeStateListener) =>
54
+ activeStateListener(),
55
+ );
36
56
  }
37
57
 
38
58
  // Add the `listener` to the list.
@@ -67,10 +87,15 @@ export default class Subscription {
67
87
  // Remove the `listener` from the list.
68
88
  this._listeners = this._listeners.filter((_) => _ !== listenerEntry);
69
89
 
70
- // If it was the last listener, deactivate subscription.
90
+ // If it was the last listener.
71
91
  if (this._listeners.length === 0) {
72
- this._deactivateSubscription();
73
- this._deactivateSubscription = undefined;
92
+ // Run any subscription inactive state listeners,
93
+ // after which clear the list of such listeners.
94
+ for (const inactiveStateListener of this
95
+ ._subscriptionInactiveStateListeners) {
96
+ inactiveStateListener();
97
+ }
98
+ this._subscriptionInactiveStateListeners = [];
74
99
  }
75
100
  }
76
101
  }
@@ -0,0 +1,71 @@
1
+ // "The more recent RFC3986 reserves !, ', (, ), and *,
2
+ // even though these characters have no formalized URI delimiting uses.
3
+ //
4
+ // https://datatracker.ietf.org/doc/html/rfc3986
5
+ //
6
+ // The following function encodes a string for RFC3986-compliant URL component format.
7
+ // It also encodes [ and ], which are part of the IPv6 URI syntax.
8
+ //
9
+ // An RFC3986-compliant encodeURI implementation should not escape them,
10
+ // which is demonstrated in the encodeURI() example.
11
+ //
12
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
13
+ //
14
+ // Can throw a `URIError` if the `string` contains a "lone surrogate".
15
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#utf-16_characters_unicode_code_points_and_grapheme_clusters
16
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/URIError
17
+ // Example: "URIError: malformed URI sequence"
18
+ //
19
+ function encode(string) {
20
+ return encodeURIComponent(string).replace(
21
+ /[!'()*]/g,
22
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
23
+ );
24
+ }
25
+
26
+ export default function stringifyQuery(query) {
27
+ let queryString = '';
28
+
29
+ if (!query) {
30
+ return queryString;
31
+ }
32
+
33
+ for (const key of Object.keys(query)) {
34
+ let value = query[key];
35
+
36
+ if (Array.isArray(value)) {
37
+ throw new Error('Array values are not supported');
38
+ }
39
+
40
+ // Ignore `value: undefined`.
41
+ if (value === undefined) {
42
+ continue;
43
+ }
44
+
45
+ // Stringify `value`.
46
+ if (value === null) {
47
+ value = '';
48
+ } else {
49
+ value = String(value);
50
+ }
51
+
52
+ // Can throw a `URIError` if the `string` contains a "lone surrogate".
53
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#utf-16_characters_unicode_code_points_and_grapheme_clusters
54
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/URIError
55
+ // Example: "URIError: malformed URI sequence"
56
+ try {
57
+ const keyValuePair = `${encode(key)}${value ? '=' : ''}${encode(value)}`;
58
+
59
+ if (queryString.length > 1) {
60
+ queryString += '&';
61
+ }
62
+
63
+ queryString += keyValuePair;
64
+ } catch (error) {
65
+ // Simply ignore an invalid query parameter.
66
+ continue;
67
+ }
68
+ }
69
+
70
+ return queryString;
71
+ }
@@ -0,0 +1,9 @@
1
+ import stringifyQuery from './stringifyQuery';
2
+
3
+ export default function stringifyQueryAsSearch(query) {
4
+ const queryString = stringifyQuery(query);
5
+ if (queryString) {
6
+ return `?${queryString}`;
7
+ }
8
+ return '';
9
+ }
@@ -0,0 +1,50 @@
1
+ import NavigationStack from '../src/NavigationStack';
2
+ import InMemoryEnvironment from '../src/environment/InMemoryEnvironment';
3
+
4
+ describe('NavigationStack (addBasePath)', () => {
5
+ it('should add `basePath` to `location.pathname`', () => {
6
+ const navigationStack = new NavigationStack(InMemoryEnvironment, {
7
+ basePath: '/base',
8
+ });
9
+ navigationStack.init('/path');
10
+ // eslint-disable-next-line no-underscore-dangle
11
+ expect(navigationStack._session._subscription._latest.pathname).to.equal(
12
+ '/base/path',
13
+ );
14
+ navigationStack.stop();
15
+ });
16
+
17
+ it('should add `basePath` (with a trailing slash) to `location.pathname`', () => {
18
+ const navigationStack = new NavigationStack(InMemoryEnvironment, {
19
+ basePath: '/base/',
20
+ });
21
+ navigationStack.init('/path');
22
+ // eslint-disable-next-line no-underscore-dangle
23
+ expect(navigationStack._session._subscription._latest.pathname).to.equal(
24
+ '/base/path',
25
+ );
26
+ navigationStack.stop();
27
+ });
28
+
29
+ it('should not modify `location.pathname` when no `basePath` was specified', () => {
30
+ const navigationStack = new NavigationStack(InMemoryEnvironment);
31
+ navigationStack.init('/path');
32
+ // eslint-disable-next-line no-underscore-dangle
33
+ expect(navigationStack._session._subscription._latest.pathname).to.equal(
34
+ '/path',
35
+ );
36
+ navigationStack.stop();
37
+ });
38
+
39
+ it('should not modify `location.pathname` when `basePath: "/"` was specified', () => {
40
+ const navigationStack = new NavigationStack(InMemoryEnvironment, {
41
+ basePath: '/',
42
+ });
43
+ navigationStack.init('/path');
44
+ // eslint-disable-next-line no-underscore-dangle
45
+ expect(navigationStack._session._subscription._latest.pathname).to.equal(
46
+ '/path',
47
+ );
48
+ navigationStack.stop();
49
+ });
50
+ });
@@ -1,56 +1,48 @@
1
1
  import delay from 'delay';
2
2
  import pDefer from 'p-defer';
3
- import { applyMiddleware, createStore } from 'redux';
4
3
 
5
- import addNavigationBlockerOriginal from '../../../src/addNavigationBlocker';
6
- import Actions from '../../../src/redux/Actions';
7
- import createMiddlewares from '../../../src/redux/createMiddlewares';
8
- import internalLocationReducer from '../../../src/redux/internalLocationReducer';
9
- import InMemorySession from '../../../src/session/InMemorySession';
4
+ import NavigationStack from '../src/NavigationStack';
5
+ import addNavigationBlockerOriginal from '../src/addNavigationBlocker';
6
+ import InMemoryEnvironment from '../src/environment/InMemoryEnvironment';
10
7
 
11
- describe('createNonProgrammaticNavigationBlockerMiddleware', () => {
8
+ describe('NavigationStack (blockNonProgrammaticNavigationIfRequired)', () => {
12
9
  // const sandbox = sinon.createSandbox();
13
10
 
14
11
  let session;
15
- let store;
12
+ let navigationStack;
16
13
 
17
14
  function addNavigationBlocker(blocker) {
18
15
  return addNavigationBlockerOriginal(session, blocker);
19
16
  }
20
17
 
21
18
  beforeEach(() => {
22
- session = new InMemorySession();
23
-
24
- store = createStore(
25
- internalLocationReducer,
26
- applyMiddleware(
27
- ...createMiddlewares(session, {
28
- _internalLocationReducer: true,
29
- }),
30
- ),
31
- );
32
- store.dispatch(Actions.init('/initial'));
33
-
34
- sinon.spy(session.lifecycle, 'addTerminationBlocker');
19
+ navigationStack = new NavigationStack(InMemoryEnvironment);
20
+
21
+ // eslint-disable-next-line no-underscore-dangle
22
+ session = navigationStack._session;
23
+
24
+ navigationStack.init('/initial');
25
+
26
+ sinon.spy(session.environment.lifecycle, 'addTerminationBlocker');
35
27
  });
36
28
 
37
29
  afterEach(() => {
38
- store.dispatch(Actions.stop());
30
+ navigationStack.stop();
39
31
 
40
32
  // sandbox.restore();
41
33
  });
42
34
 
43
35
  describe('shift navigation', () => {
44
36
  beforeEach(() => {
45
- store.dispatch(Actions.push('/new'));
37
+ navigationStack.push('/new');
46
38
  });
47
39
 
48
40
  it('should allow navigation when blocker returns `undefined`', () => {
49
41
  const blocker = sinon.stub().returns(undefined);
50
42
  addNavigationBlocker(blocker);
51
43
 
52
- store.dispatch(Actions.shift(-1));
53
- expect(store.getState().pathname).to.equal('/initial');
44
+ navigationStack.shift(-1);
45
+ expect(navigationStack.current().pathname).to.equal('/initial');
54
46
 
55
47
  expect(blocker.firstCall.args[0]).to.include({
56
48
  // operation: 'shift',
@@ -62,34 +54,34 @@ describe('createNonProgrammaticNavigationBlockerMiddleware', () => {
62
54
  it('should block navigation when blocker returns `true`', () => {
63
55
  addNavigationBlocker(() => true);
64
56
 
65
- store.dispatch(Actions.shift(-1));
66
- expect(store.getState().pathname).to.equal('/new');
57
+ navigationStack.shift(-1);
58
+ expect(navigationStack.current().pathname).to.equal('/new');
67
59
  });
68
60
 
69
61
  it('should allow navigation when blocker returns `undefined` (async)', async () => {
70
62
  const navigationBlockerDeferred = pDefer();
71
63
  addNavigationBlocker(() => navigationBlockerDeferred.promise);
72
64
 
73
- store.dispatch(Actions.shift(-1));
74
- expect(store.getState().pathname).to.equal('/new');
65
+ navigationStack.shift(-1);
66
+ expect(navigationStack.current().pathname).to.equal('/new');
75
67
 
76
68
  navigationBlockerDeferred.resolve(undefined);
77
69
  await delay(10);
78
70
 
79
- expect(store.getState().pathname).to.equal('/initial');
71
+ expect(navigationStack.current().pathname).to.equal('/initial');
80
72
  });
81
73
 
82
74
  it('should block navigation when blocker returns `true` (async)', async () => {
83
75
  const navigationBlockerDeferred = pDefer();
84
76
  addNavigationBlocker(() => navigationBlockerDeferred.promise);
85
77
 
86
- store.dispatch(Actions.shift(-1));
87
- expect(store.getState().pathname).to.equal('/new');
78
+ navigationStack.shift(-1);
79
+ expect(navigationStack.current().pathname).to.equal('/new');
88
80
 
89
81
  navigationBlockerDeferred.resolve(true);
90
82
  await delay(10);
91
83
 
92
- expect(store.getState().pathname).to.equal('/new');
84
+ expect(navigationStack.current().pathname).to.equal('/new');
93
85
  });
94
86
 
95
87
  // it('should show a confirmation dialog and allow navigation on string', () => {
@@ -97,33 +89,14 @@ describe('createNonProgrammaticNavigationBlockerMiddleware', () => {
97
89
  //
98
90
  // addNavigationBlocker(({ pathname }) => pathname);
99
91
  //
100
- // store.dispatch(Actions.shift(-1));
101
- // expect(store.getState().pathname).to.equal('/initial');
92
+ // navigationStack.shift(-1));
93
+ // expect(navigationStack.current().pathname).to.equal('/initial');
102
94
  //
103
95
  // expect(window.confirm)
104
96
  // .to.have.been.calledOnce()
105
97
  // .and.to.have.been.called.with('/new');
106
98
  // });
107
99
 
108
- it('should ignore the initial load when blocker returns `true`', () => {
109
- // Get rid of the old store. We'll replace it with a new one.
110
- store.dispatch(Actions.stop());
111
-
112
- store = createStore(
113
- internalLocationReducer,
114
- applyMiddleware(
115
- ...createMiddlewares(new InMemorySession(), {
116
- _internalLocationReducer: true,
117
- }),
118
- ),
119
- );
120
- addNavigationBlocker(() => true);
121
-
122
- expect(store.getState()).to.be.undefined();
123
- store.dispatch(Actions.init('/initial'));
124
- expect(store.getState().pathname).to.equal('/initial');
125
- });
126
-
127
100
  it('should support async rewinding', async () => {
128
101
  // eslint-disable-next-line no-underscore-dangle
129
102
  if (session._subscription._listeners.length !== 2) {
@@ -152,7 +125,7 @@ describe('createNonProgrammaticNavigationBlockerMiddleware', () => {
152
125
  const navigationBlockerDeferred = pDefer();
153
126
  addNavigationBlocker(() => navigationBlockerDeferred.promise);
154
127
 
155
- store.dispatch(Actions.shift(-1));
128
+ navigationStack.shift(-1);
156
129
 
157
130
  // current location was updated immediately.
158
131
  // Any `.subscribe()` listeners haven't yet been called,
@@ -162,7 +135,7 @@ describe('createNonProgrammaticNavigationBlockerMiddleware', () => {
162
135
  // eslint-disable-next-line no-underscore-dangle
163
136
  expect(session._subscription._latest.pathname).to.equal('/initial');
164
137
  // navigation is waiting.
165
- expect(store.getState().pathname).to.equal('/new');
138
+ expect(navigationStack.current().pathname).to.equal('/new');
166
139
 
167
140
  // proceed with navigation.
168
141
  navigationDeferred.resolve();
@@ -172,7 +145,7 @@ describe('createNonProgrammaticNavigationBlockerMiddleware', () => {
172
145
  // eslint-disable-next-line no-underscore-dangle
173
146
  expect(session._subscription._latest.pathname).to.equal('/new');
174
147
  // navigation almost finished: navigation blockers are running.
175
- expect(store.getState().pathname).to.equal('/new');
148
+ expect(navigationStack.current().pathname).to.equal('/new');
176
149
 
177
150
  // finish navigation blockers.
178
151
  navigationBlockerDeferred.resolve(undefined);
@@ -187,7 +160,7 @@ describe('createNonProgrammaticNavigationBlockerMiddleware', () => {
187
160
  expect(session._subscription._latest.pathname).to.equal('/initial');
188
161
  // navigation finished.
189
162
  // wasn't blocked.
190
- expect(store.getState().pathname).to.equal('/initial');
163
+ expect(navigationStack.current().pathname).to.equal('/initial');
191
164
  });
192
165
 
193
166
  // it('should allow navigation without calling any blockers when `location.delta` is `null`', async () => {
@@ -211,7 +184,7 @@ describe('createNonProgrammaticNavigationBlockerMiddleware', () => {
211
184
  // // Without delta, we can't rewind the location change,
212
185
  // // so navigation is allowed without calling any blockers.
213
186
  // expect(currentNavigationLocation.pathname).to.equal('/initial');
214
- // expect(store.getState().pathname).to.equal('/initial');
187
+ // expect(navigationStack.current().pathname).to.equal('/initial');
215
188
  // });
216
189
 
217
190
  // it('should allow navigation when blocker returns `undefined` and `location.delta` is `null`', async () => {
@@ -229,13 +202,13 @@ describe('createNonProgrammaticNavigationBlockerMiddleware', () => {
229
202
  //
230
203
  // // Without delta, we can't rewind on the session.
231
204
  // expect(currentNavigationLocation.pathname).to.equal('/initial');
232
- // expect(store.getState().pathname).to.equal('/new');
205
+ // expect(navigationStack.current().pathname).to.equal('/new');
233
206
  //
234
207
  // navigationBlockerDeferred.resolve(undefined);
235
208
  // await delay(10);
236
209
  //
237
210
  // expect(currentNavigationLocation.pathname).to.equal('/initial');
238
- // expect(store.getState().pathname).to.equal('/initial');
211
+ // expect(navigationStack.current().pathname).to.equal('/initial');
239
212
  // });
240
213
 
241
214
  // it('should block store update when blocker returns `true` and `location.delta` is `null`', async () => {
@@ -251,14 +224,29 @@ describe('createNonProgrammaticNavigationBlockerMiddleware', () => {
251
224
  // /* eslint-enable no-underscore-dangle */
252
225
  //
253
226
  // expect(session._navigation.getInitialLocation().pathname).to.equal('/initial');
254
- // expect(store.getState().pathname).to.equal('/new');
227
+ // expect(navigationStack.current().pathname).to.equal('/new');
255
228
  //
256
229
  // navigationBlockerDeferred.resolve(true);
257
230
  // await delay(10);
258
231
  //
259
232
  // // These are out-of-sync now, but it's the best we can do.
260
233
  // expect(session._navigation.getInitialLocation().pathname).to.equal('/initial');
261
- // expect(store.getState().pathname).to.equal('/new');
234
+ // expect(navigationStack.current().pathname).to.equal('/new');
262
235
  // });
263
236
  });
264
237
  });
238
+
239
+ describe('NavigationStack (blockNonProgrammaticNavigationIfRequired) (init)', () => {
240
+ it('should allow the initial load even when a navigation blocker returns `true`', () => {
241
+ const navigationStack = new NavigationStack(InMemoryEnvironment);
242
+ // eslint-disable-next-line no-underscore-dangle
243
+ const session = navigationStack._session;
244
+ addNavigationBlockerOriginal(session, () => true);
245
+
246
+ // eslint-disable-next-line no-underscore-dangle
247
+ expect(navigationStack._location).to.be.undefined();
248
+ navigationStack.init('/initial');
249
+ // eslint-disable-next-line no-underscore-dangle
250
+ expect(navigationStack._location.pathname).to.equal('/initial');
251
+ });
252
+ });