navigation-stack 0.3.1 → 0.5.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 (257) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +611 -163
  3. package/data-storage/package.json +6 -0
  4. package/karma.conf.cjs +21 -4
  5. package/lib/cjs/NavigationStack.js +88 -0
  6. package/lib/cjs/data-storage/DataStorage.js +71 -0
  7. package/lib/cjs/data-storage/LocationDataStorage.js +29 -0
  8. package/lib/cjs/data-storage/index.js +9 -0
  9. package/lib/cjs/debug.js +12 -0
  10. package/lib/cjs/environment/InMemoryEnvironment.js +15 -0
  11. package/lib/cjs/environment/WebBrowserEnvironment.js +15 -0
  12. package/lib/cjs/environment/data-storage/InMemoryDataStorage.js +27 -0
  13. package/lib/cjs/environment/data-storage/WebBrowserDataStorage.js +21 -0
  14. package/lib/cjs/environment/scroll-position/InMemoryScrollPosition.js +44 -0
  15. package/lib/cjs/environment/scroll-position/WebBrowserScrollPosition.js +60 -0
  16. package/lib/cjs/getLocationFromInternalLocation.js +14 -0
  17. package/lib/cjs/index.js +20 -16
  18. package/lib/cjs/navigationBlockers.js +28 -23
  19. package/lib/cjs/{normalizeInputLocation.js → parseInputLocation.js} +25 -9
  20. package/lib/cjs/{ActionTypes.js → redux/ActionTypes.js} +1 -1
  21. package/lib/cjs/redux/ActionTypesInternal.js +8 -0
  22. package/lib/cjs/{Actions.js → redux/Actions.js} +5 -4
  23. package/lib/cjs/redux/createMiddlewares.js +60 -0
  24. package/lib/cjs/redux/index.js +13 -0
  25. package/lib/cjs/redux/internalLocationReducer.js +14 -0
  26. package/lib/cjs/redux/middleware/createAddInputLocationBasePathMiddleware.js +32 -0
  27. package/lib/cjs/redux/middleware/createNonProgrammaticNavigationBlockerMiddleware.js +113 -0
  28. package/lib/cjs/redux/middleware/createProgrammaticNavigationBlockerMiddleware.js +94 -0
  29. package/lib/cjs/redux/middleware/createRemoveOutputLocationBasePathMiddleware.js +30 -0
  30. package/lib/cjs/redux/middleware/createUpdateInternalLocationMiddleware.js +73 -0
  31. package/lib/cjs/{middleware/navigationActionMiddleware.js → redux/middleware/navigationOperationMiddleware.js} +11 -8
  32. package/lib/cjs/{middleware/normalizeInputLocationMiddleware.js → redux/middleware/parseInputLocationMiddleware.js} +6 -4
  33. package/lib/cjs/redux/middleware/updateLocationMiddleware.js +34 -0
  34. package/lib/cjs/scroll-position/PageScrollPositionSetter.js +97 -0
  35. package/lib/cjs/scroll-position/ScrollPositionAutoSaver.js +141 -0
  36. package/lib/cjs/scroll-position/ScrollPositionRestoration.js +407 -0
  37. package/lib/cjs/scroll-position/ScrollPositionSaver.js +87 -0
  38. package/lib/cjs/scroll-position/ScrollPositionSetter.js +16 -0
  39. package/lib/cjs/scroll-position/constants.js +5 -0
  40. package/lib/cjs/scroll-position/index.js +7 -0
  41. package/lib/cjs/scroll-position/scheduleNextTick.js +11 -0
  42. package/lib/cjs/session/InMemorySession.js +22 -0
  43. package/lib/cjs/session/ServerSideRenderSession.js +17 -0
  44. package/lib/cjs/session/Session.js +202 -0
  45. package/lib/cjs/session/WebBrowserSession.js +20 -0
  46. package/lib/cjs/session/key/createSessionKey.js +23 -0
  47. package/lib/cjs/session/lifecycle/InMemorySessionLifecycle.js +19 -0
  48. package/lib/cjs/session/lifecycle/WebBrowserSessionLifecycle.js +128 -0
  49. package/lib/cjs/session/lifecycle/page-lifecycle/PageLifecycle.js +269 -0
  50. package/lib/cjs/session/lifecycle/page-lifecycle/PageLifecycleInstance.js +8 -0
  51. package/lib/cjs/session/lifecycle/page-lifecycle/supportsConstructableEventTarget.js +33 -0
  52. package/lib/cjs/session/navigation/InMemoryNavigation.js +104 -0
  53. package/lib/cjs/session/navigation/ServerSideNavigation.js +61 -0
  54. package/lib/cjs/session/navigation/WebBrowserNavigation.js +221 -0
  55. package/lib/cjs/session/navigation/error/NavigationOutOfBoundsError.js +12 -0
  56. package/lib/cjs/session/navigation/error/ServerSideNavigationError.js +21 -0
  57. package/lib/cjs/session/navigation/operation/operations.js +11 -0
  58. package/lib/cjs/session/subscription/Subscription.js +81 -0
  59. package/lib/data-storage/index.d.ts +35 -0
  60. package/lib/esm/NavigationStack.js +81 -0
  61. package/lib/esm/data-storage/DataStorage.js +65 -0
  62. package/lib/esm/data-storage/LocationDataStorage.js +22 -0
  63. package/lib/esm/data-storage/index.js +2 -0
  64. package/lib/esm/debug.js +7 -0
  65. package/lib/esm/environment/InMemoryEnvironment.js +8 -0
  66. package/lib/esm/environment/WebBrowserEnvironment.js +8 -0
  67. package/lib/esm/environment/data-storage/InMemoryDataStorage.js +21 -0
  68. package/lib/esm/environment/data-storage/WebBrowserDataStorage.js +15 -0
  69. package/lib/esm/environment/scroll-position/InMemoryScrollPosition.js +38 -0
  70. package/lib/esm/environment/scroll-position/WebBrowserScrollPosition.js +54 -0
  71. package/lib/esm/getLocationFromInternalLocation.js +9 -0
  72. package/lib/esm/index.js +10 -8
  73. package/lib/esm/navigationBlockers.js +28 -23
  74. package/lib/esm/{normalizeInputLocation.js → parseInputLocation.js} +24 -8
  75. package/lib/esm/{ActionTypes.js → redux/ActionTypes.js} +1 -1
  76. package/lib/esm/redux/ActionTypesInternal.js +3 -0
  77. package/lib/esm/{Actions.js → redux/Actions.js} +5 -4
  78. package/lib/esm/redux/createMiddlewares.js +54 -0
  79. package/lib/esm/redux/index.js +4 -0
  80. package/lib/esm/redux/internalLocationReducer.js +8 -0
  81. package/lib/esm/redux/middleware/createAddInputLocationBasePathMiddleware.js +27 -0
  82. package/lib/esm/redux/middleware/createNonProgrammaticNavigationBlockerMiddleware.js +108 -0
  83. package/lib/esm/redux/middleware/createProgrammaticNavigationBlockerMiddleware.js +88 -0
  84. package/lib/esm/redux/middleware/createRemoveOutputLocationBasePathMiddleware.js +25 -0
  85. package/lib/esm/redux/middleware/createUpdateInternalLocationMiddleware.js +68 -0
  86. package/lib/esm/{middleware/navigationActionMiddleware.js → redux/middleware/navigationOperationMiddleware.js} +10 -7
  87. package/lib/esm/{middleware/normalizeInputLocationMiddleware.js → redux/middleware/parseInputLocationMiddleware.js} +5 -3
  88. package/lib/esm/redux/middleware/updateLocationMiddleware.js +28 -0
  89. package/lib/esm/scroll-position/PageScrollPositionSetter.js +91 -0
  90. package/lib/esm/scroll-position/ScrollPositionAutoSaver.js +134 -0
  91. package/lib/esm/scroll-position/ScrollPositionRestoration.js +400 -0
  92. package/lib/esm/scroll-position/ScrollPositionSaver.js +80 -0
  93. package/lib/esm/scroll-position/ScrollPositionSetter.js +10 -0
  94. package/lib/esm/scroll-position/constants.js +1 -0
  95. package/lib/esm/scroll-position/index.js +1 -0
  96. package/lib/esm/scroll-position/scheduleNextTick.js +6 -0
  97. package/lib/esm/session/InMemorySession.js +15 -0
  98. package/lib/esm/session/ServerSideRenderSession.js +11 -0
  99. package/lib/esm/session/Session.js +195 -0
  100. package/lib/esm/session/WebBrowserSession.js +13 -0
  101. package/lib/esm/session/key/createSessionKey.js +18 -0
  102. package/lib/esm/session/lifecycle/InMemorySessionLifecycle.js +13 -0
  103. package/lib/esm/session/lifecycle/WebBrowserSessionLifecycle.js +120 -0
  104. package/lib/esm/session/lifecycle/page-lifecycle/PageLifecycle.js +263 -0
  105. package/lib/esm/session/lifecycle/page-lifecycle/PageLifecycleInstance.js +2 -0
  106. package/lib/esm/session/lifecycle/page-lifecycle/supportsConstructableEventTarget.js +30 -0
  107. package/lib/esm/session/navigation/InMemoryNavigation.js +97 -0
  108. package/lib/esm/session/navigation/ServerSideNavigation.js +54 -0
  109. package/lib/esm/session/navigation/WebBrowserNavigation.js +213 -0
  110. package/lib/esm/session/navigation/error/NavigationOutOfBoundsError.js +6 -0
  111. package/lib/esm/session/navigation/error/ServerSideNavigationError.js +14 -0
  112. package/lib/esm/session/navigation/operation/operations.js +6 -0
  113. package/lib/esm/session/subscription/Subscription.js +75 -0
  114. package/lib/index.d.ts +179 -157
  115. package/lib/redux/index.d.ts +90 -0
  116. package/lib/scroll-position/index.d.ts +107 -0
  117. package/package.json +9 -5
  118. package/redux/package.json +6 -0
  119. package/scroll-position/package.json +6 -0
  120. package/src/NavigationStack.js +100 -0
  121. package/src/data-storage/DataStorage.js +69 -0
  122. package/src/data-storage/LocationDataStorage.js +23 -0
  123. package/src/data-storage/index.js +2 -0
  124. package/src/debug.js +8 -0
  125. package/src/environment/InMemoryEnvironment.js +9 -0
  126. package/src/environment/WebBrowserEnvironment.js +9 -0
  127. package/src/environment/data-storage/InMemoryDataStorage.js +23 -0
  128. package/src/environment/data-storage/WebBrowserDataStorage.js +17 -0
  129. package/src/environment/scroll-position/InMemoryScrollPosition.js +45 -0
  130. package/src/environment/scroll-position/WebBrowserScrollPosition.js +72 -0
  131. package/src/getLocationFromInternalLocation.js +7 -0
  132. package/src/index.js +10 -8
  133. package/src/navigationBlockers.js +31 -27
  134. package/src/{normalizeInputLocation.js → parseInputLocation.js} +23 -8
  135. package/src/{ActionTypes.js → redux/ActionTypes.js} +1 -1
  136. package/src/redux/ActionTypesInternal.js +3 -0
  137. package/src/{Actions.js → redux/Actions.js} +4 -3
  138. package/src/redux/createMiddlewares.js +65 -0
  139. package/src/redux/index.js +4 -0
  140. package/src/redux/internalLocationReducer.js +9 -0
  141. package/src/redux/middleware/createAddInputLocationBasePathMiddleware.js +27 -0
  142. package/src/redux/middleware/createNonProgrammaticNavigationBlockerMiddleware.js +119 -0
  143. package/src/redux/middleware/createProgrammaticNavigationBlockerMiddleware.js +94 -0
  144. package/src/redux/middleware/createRemoveOutputLocationBasePathMiddleware.js +26 -0
  145. package/src/redux/middleware/createUpdateInternalLocationMiddleware.js +72 -0
  146. package/src/{middleware/navigationActionMiddleware.js → redux/middleware/navigationOperationMiddleware.js} +10 -3
  147. package/src/{middleware/normalizeInputLocationMiddleware.js → redux/middleware/parseInputLocationMiddleware.js} +5 -3
  148. package/src/redux/middleware/updateLocationMiddleware.js +28 -0
  149. package/src/scroll-position/PageScrollPositionSetter.js +110 -0
  150. package/src/scroll-position/ScrollPositionAutoSaver.js +168 -0
  151. package/src/scroll-position/ScrollPositionRestoration.js +551 -0
  152. package/src/scroll-position/ScrollPositionSaver.js +120 -0
  153. package/src/scroll-position/ScrollPositionSetter.js +16 -0
  154. package/src/scroll-position/constants.js +1 -0
  155. package/src/scroll-position/index.js +1 -0
  156. package/src/scroll-position/scheduleNextTick.js +6 -0
  157. package/src/session/InMemorySession.js +13 -0
  158. package/src/session/ServerSideRenderSession.js +9 -0
  159. package/src/session/Session.js +238 -0
  160. package/src/session/WebBrowserSession.js +13 -0
  161. package/src/session/key/createSessionKey.js +18 -0
  162. package/src/session/lifecycle/InMemorySessionLifecycle.js +13 -0
  163. package/src/session/lifecycle/WebBrowserSessionLifecycle.js +126 -0
  164. package/src/session/lifecycle/page-lifecycle/PageLifecycle.js +291 -0
  165. package/src/session/lifecycle/page-lifecycle/PageLifecycleInstance.js +3 -0
  166. package/src/session/lifecycle/page-lifecycle/supportsConstructableEventTarget.js +32 -0
  167. package/src/session/navigation/InMemoryNavigation.js +78 -0
  168. package/src/session/navigation/ServerSideNavigation.js +43 -0
  169. package/src/session/navigation/WebBrowserNavigation.js +224 -0
  170. package/src/session/navigation/error/NavigationOutOfBoundsError.js +7 -0
  171. package/src/session/navigation/error/ServerSideNavigationError.js +18 -0
  172. package/src/session/navigation/operation/operations.js +6 -0
  173. package/src/session/subscription/Subscription.js +76 -0
  174. package/test/NavigationStack.test.js +296 -0
  175. package/test/{LocationDataStorage.test.js → data-storage/LocationDataStorage.test.js} +3 -3
  176. package/test/data-storage/index.test.js +8 -0
  177. package/test/index.js +12 -0
  178. package/test/index.test.js +8 -7
  179. package/test/{helpers.js → middlewareTestUtil.js} +9 -12
  180. package/test/{normalizeInputLocation.test.js → parseInputLocationMiddleware.test.js} +9 -9
  181. package/test/{Action.test.js → redux/Action.test.js} +7 -6
  182. package/test/{ActionTypes.test.js → redux/ActionTypes.test.js} +2 -2
  183. package/test/redux/createMiddlewares.test.js +96 -0
  184. package/test/redux/index.test.js +10 -0
  185. package/test/{locationReducer.test.js → redux/locationReducer.test.js} +4 -7
  186. package/test/redux/middleware/createAddInputLocationBasePathMiddleware.test.js +40 -0
  187. package/test/redux/middleware/createNonProgrammaticNavigationBlockerMiddleware.test.js +264 -0
  188. package/test/redux/middleware/createProgrammaticNavigationBlockerMiddleware.test.js +312 -0
  189. package/test/redux/middleware/createRemoveOutputLocationBasePathMiddleware.test.js +51 -0
  190. package/test/{middleware/navigationActionMiddleware.test.js → redux/middleware/navigationOperationMiddleware.test.js} +16 -12
  191. package/test/{middleware/normalizeInputLocationMiddleware.test.js → redux/middleware/parseInputLocationMiddleware.test.js} +4 -4
  192. package/test/scroll-position/ScrollPositionRestoration.test.js +435 -0
  193. package/test/scroll-position/addScrollableContainer.js +39 -0
  194. package/test/scroll-position/addScrollableContainerWithAnchors.js +56 -0
  195. package/test/scroll-position/createApp.js +132 -0
  196. package/test/scroll-position/delay.js +9 -0
  197. package/test/scroll-position/mockPageLifecycle.js +17 -0
  198. package/test/scroll-position/runApp.js +24 -0
  199. package/test/scroll-position/withScrollableContainerAtIndexPageWithDisabledAutomaticScrollPositionRestoration.js +72 -0
  200. package/test/session/InMemorySession.test.js +348 -0
  201. package/test/session/ServerSession.test.js +17 -9
  202. package/test/session/WebBrowserSession.test.js +265 -0
  203. package/test/testUtil.js +3 -0
  204. package/types/data-storage/index.d.ts +35 -0
  205. package/types/index.d.ts +179 -157
  206. package/types/redux/index.d.ts +90 -0
  207. package/types/scroll-position/index.d.ts +107 -0
  208. package/types/tsconfig.json +1 -1
  209. package/lib/cjs/LocationDataStorage.js +0 -61
  210. package/lib/cjs/addBeforeLocationChangeListener.js +0 -7
  211. package/lib/cjs/beforeLocationChangeListeners.js +0 -51
  212. package/lib/cjs/createMiddlewares.js +0 -47
  213. package/lib/cjs/middleware/createBasePathMiddleware.js +0 -24
  214. package/lib/cjs/middleware/createBeforeLocationChangeListenerMiddleware.js +0 -39
  215. package/lib/cjs/middleware/createLocationMiddleware.js +0 -56
  216. package/lib/cjs/middleware/createNavigationBlockerMiddleware.js +0 -161
  217. package/lib/cjs/middleware/createTransformLocationMiddleware.js +0 -38
  218. package/lib/cjs/onlyAllowedOnClientSide.js +0 -10
  219. package/lib/cjs/session/BrowserSession.js +0 -235
  220. package/lib/cjs/session/MemorySession.js +0 -223
  221. package/lib/cjs/session/ServerSession.js +0 -65
  222. package/lib/esm/LocationDataStorage.js +0 -54
  223. package/lib/esm/addBeforeLocationChangeListener.js +0 -2
  224. package/lib/esm/beforeLocationChangeListeners.js +0 -44
  225. package/lib/esm/createMiddlewares.js +0 -41
  226. package/lib/esm/middleware/createBasePathMiddleware.js +0 -19
  227. package/lib/esm/middleware/createBeforeLocationChangeListenerMiddleware.js +0 -34
  228. package/lib/esm/middleware/createLocationMiddleware.js +0 -50
  229. package/lib/esm/middleware/createNavigationBlockerMiddleware.js +0 -156
  230. package/lib/esm/middleware/createTransformLocationMiddleware.js +0 -33
  231. package/lib/esm/onlyAllowedOnClientSide.js +0 -5
  232. package/lib/esm/session/BrowserSession.js +0 -229
  233. package/lib/esm/session/MemorySession.js +0 -217
  234. package/lib/esm/session/ServerSession.js +0 -58
  235. package/src/LocationDataStorage.js +0 -60
  236. package/src/addBeforeLocationChangeListener.js +0 -2
  237. package/src/beforeLocationChangeListeners.js +0 -54
  238. package/src/createMiddlewares.js +0 -45
  239. package/src/middleware/createBasePathMiddleware.js +0 -20
  240. package/src/middleware/createBeforeLocationChangeListenerMiddleware.js +0 -40
  241. package/src/middleware/createLocationMiddleware.js +0 -55
  242. package/src/middleware/createNavigationBlockerMiddleware.js +0 -168
  243. package/src/middleware/createTransformLocationMiddleware.js +0 -29
  244. package/src/onlyAllowedOnClientSide.js +0 -5
  245. package/src/session/BrowserSession.js +0 -235
  246. package/src/session/MemorySession.js +0 -219
  247. package/src/session/ServerSession.js +0 -67
  248. package/test/createMiddlewares.test.js +0 -62
  249. package/test/middleware/createBasePathMiddleware.test.js +0 -67
  250. package/test/middleware/createBeforeLocationChangeListenerMiddleware.test.js +0 -141
  251. package/test/middleware/createNavigationBlockerMiddleware.test.js +0 -471
  252. package/test/middleware/createTransformLocationMiddleware.test.js +0 -44
  253. package/test/session/BrowserSession.test.js +0 -182
  254. package/test/session/MemorySession.test.js +0 -244
  255. /package/lib/cjs/{locationReducer.js → redux/locationReducer.js} +0 -0
  256. /package/lib/esm/{locationReducer.js → redux/locationReducer.js} +0 -0
  257. /package/src/{locationReducer.js → redux/locationReducer.js} +0 -0
package/README.md CHANGED
@@ -3,9 +3,11 @@
3
3
  [![npm version](https://img.shields.io/npm/v/navigation-stack.svg?style=flat-square)](https://www.npmjs.com/package/navigation-stack)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/navigation-stack.svg?style=flat-square)](https://www.npmjs.com/package/navigation-stack)
5
5
 
6
- Handles navigation in a web browser. Represents web browser navigation history as a "stack" data structure. Provides operations to perform programmatic navigation such as "push" (go to new URL), "replace" (redirect to new URL), "shift" (rewind to a previously visited URL). Provides a subscription mechanism to get notified on current location change.
6
+ Handles navigation in a web browser. Represents web browser navigation history as a "stack" data structure. Provides operations to perform programmatic navigation such as "push" (go to new URL), "replace" (redirect to new URL), "shift" (rewind to a previously visited URL). Provides a subscription mechanism to get notified on location changes.
7
7
 
8
- Originally forked from [`farce`](http://npmjs.com/package/farce) package to fix a [bug](https://github.com/4Catalyzer/farce/issues/483).
8
+ Also supports automatic [scroll position restoration](#scroll-position-restoration) on "Back"/"Forward" navigation.
9
+
10
+ Originally forked from [`farce`](http://npmjs.com/package/farce) package to fix a couple of small bugs ([1](https://github.com/4Catalyzer/farce/issues/483), [2](https://github.com/4Catalyzer/farce/issues/491)). Then merged it with [`scroll-behavior`](http://npmjs.com/package/scroll-behavior) package to fix a couple of small bugs ([1](https://github.com/taion/scroll-behavior/issues/215), [2](https://github.com/taion/scroll-behavior/pull/472)). Then decided to completely rewrite the entire code and changed the API to my liking.
9
11
 
10
12
  ## Install
11
13
 
@@ -15,60 +17,99 @@ npm install navigation-stack
15
17
 
16
18
  ## Use
17
19
 
18
- `navigation-stack` provides "middlewares", "actions" and a "reducer" that could be used with `redux` or any other `redux`-compatible package such as [`mini-redux`](https://www.npmjs.com/package/mini-redux).
19
-
20
- ```js
21
- import { createStore, applyMiddleware } from 'redux'
20
+ Any changes to a `NavigationStack` instance are "magically" reflected in the web browser's address bar and navigation history, and vice versa: any changes to the URL in the web browser's address bar are "magically" reflected in the `NavigationStack` instance. So one could think of `NavigationStack` as a very convenient proxy to web browser's address bar and navigation history. What's left to the application is to subscribe to `navigationStack` changes and re-render the page accordingly.
22
21
 
23
- import {
24
- createMiddlewares,
25
- locationReducer,
26
- Actions,
27
- BrowserSession
28
- } from 'navigation-stack'
22
+ Start by creating a `NavigationStack` instance.
29
23
 
30
- // Create a Redux store.
31
- const store = createStore(
32
- locationReducer, // Reducer function. For example, `locationReducer()`.
33
- applyMiddleware(...createMiddlewares(new BrowserSession()))
34
- )
24
+ ```js
25
+ import { NavigationStack, WebBrowserSession } from 'navigation-stack'
35
26
 
36
- // Initialize navigation.
37
- store.dispatch(Actions.init())
27
+ // Create a `NavigationStack` instance.
28
+ // It should be tied to a navigation "session".
29
+ const navigationStack = new NavigationStack(new WebBrowserSession())
38
30
  ```
39
31
 
40
- After that, dispatch any of the `Actions` in order to navigate.
32
+ Then subscribe to changes:
41
33
 
42
34
  ```js
43
- // To navigate to a new page.
44
- store.dispatch(Actions.push('/new/location'))
45
-
46
- // To redirect to a new page.
47
- store.dispatch(Actions.replace('/new/location'))
35
+ // Subscribe to location changes.
36
+ // The first call happens for the initial location.
37
+ // Next calls will happen in case of navigation.
38
+ const unsubscribe = navigationStack.subscribe((location) => {
39
+ console.log('Current location', location)
40
+ document.body.innerHTML = '<div>' + location.pathname + '</div>'
41
+ })
42
+ ```
48
43
 
49
- // To go back.
50
- store.dispatch(Actions.shift(-1))
44
+ Now ready to perform navigation actions.
51
45
 
52
- // To go forward.
53
- store.dispatch(Actions.shift(1))
46
+ ```js
47
+ // Sets the initial location.
48
+ navigationStack.init()
49
+
50
+ // Sets the `location` to be a new location.
51
+ //
52
+ // Also updates the URL in the web browser's address bar.
53
+ //
54
+ // Also adds a new entry in the web browser's navigation history.
55
+ //
56
+ navigationStack.push('/new-location')
57
+
58
+ // Sets the `location` to be a new location.
59
+ //
60
+ // Also updates the URL in the web browser's address bar.
61
+ //
62
+ // Does not add a new entry in the web browser's navigation history
63
+ // which is the only difference between this and `Actions.push()`.
64
+ //
65
+ navigationStack.replace('/new-location')
66
+
67
+ // Sets the `location` to be a previous one (if there is one).
68
+ // If there's no such `location` in the navigation history,
69
+ // throws a `NavigationOutOfBoundsError` error that has an `index` property.
70
+ //
71
+ // One could think of it as an equivalent of clicking a "Back" button in a web browser.
72
+ //
73
+ // Also updates the URL in the web browser's address bar.
74
+ //
75
+ // Also shifts the current position in the web browser's navigation history.
76
+ //
77
+ navigationStack.shift(-1)
78
+
79
+ // Sets the `location` to be a next one (if there is one).
80
+ // If there's no such `location` in the navigation history,
81
+ // throws a `NavigationOutOfBoundsError` error that has an `index` property.
82
+ //
83
+ // One could think of it as an equivalent of clicking a "Forward" button in a web browser.
84
+ //
85
+ // Also updates the URL in the web browser's address bar.
86
+ //
87
+ // Also shifts the current position in the web browser's navigation history.
88
+ //
89
+ navigationStack.shift(1)
54
90
  ```
55
91
 
56
- To view the current location:
92
+ To get the current location:
57
93
 
58
94
  ```js
59
- // When `locationReducer()` is used,
60
- // `store.getState()` is the current location.
61
- console.log(store.getState())
95
+ const location = navigationStack.current()
96
+ console.log(location)
62
97
  ```
63
98
 
64
- (optional) (advanced) Stop and clean up:
99
+ (optional) After the user is done using the app, stop the session and clean up any listeners.
65
100
 
66
101
  ```js
67
- store.dispatch(Actions.dispose())
102
+ // (optional)
103
+ // When the user closes the application,
104
+ // stop the session and clean up any listeners.
105
+ // There's no need to do this in a web browser.
106
+ unsubscribe()
107
+ navigationStack.stop()
68
108
  ```
69
109
 
70
110
  ## Current Location
71
111
 
112
+ <!--
72
113
  To track the current location, the application could listen to `ActionTypes.UPDATE` action. The `payload` of the action is the current location.
73
114
 
74
115
  For example, below is the source code for the default `locationReducer`.
@@ -88,27 +129,19 @@ function reducer(state, action) {
88
129
 
89
130
  With this reducer, `store.getState()` will return the current location.
90
131
 
91
- Calling `store.dispatch(Actions.init())` will trigger the initial `ActionTypes.UPDATE` action which will set the initial current location. From then on, the current location will always stay in sync with the web browser's URL bar, including "Back"/"Forward" navigation.
132
+ Calling `store.dispatch(Actions.init(window.location))` will trigger the initial `ActionTypes.UPDATE` action which will set the initial current location. From then on, the current location will always stay in sync with the web browser's URL bar, including "Back"/"Forward" navigation.
133
+ -->
92
134
 
93
- A `location` object has all the properties of a [standard web browser location](https://developer.mozilla.org/en-US/docs/Web/API/Window/location) with the addition of:
135
+ To get the current location, use `navigationStack.current()`.
136
+
137
+ Current `location` object has all the properties of a [standard web browser location](https://developer.mozilla.org/en-US/docs/Web/API/Window/location) with the addition of:
94
138
  * `query: object` — URL query parameters.
95
- * `index: number` — The index of the location in the navigation history, starting with `0` for the initial location.
96
- * `action: string` — The type of navigation that led to the location.
97
- * `INIT` in case of the initial location before any navigation has taken place.
98
- * `SHIFT` when the user performs a "Back" or "Forward" navigation, or after a `.shift()` navigation which is essentially a "back or forward navigation".
99
- * `PUSH` in case of a `.push()` navigation, i.e. "normal navigation via a hyperlink".
100
- * `REPLACE` in case of a `.replace()` navigation, i.e. "redirect".
101
- * `delta: number` — the difference between the `index` of the current location and the `index` of the previous location.
102
- * `0` for the initial location before any navigation has taken place.
103
- * `1` after a `.push()` navigation, i.e. "normal navigation via a hyperlink".
104
- * `0` after a `.replace()` navigation, i.e. "redirect".
105
- * `delta: number` after a `.shift(delta)` navigation, i.e. "back or forward navigation".
106
- * `-1` after the user clicks a "Back" button in their web browser.
107
- * `1` after the user clicks a "Forward" button in their web browser.
108
- * `key: string` — a unique ID of the `location` object within the navigation history.
109
-
110
- ## Subscribe to Location Changes
139
+ * `key: string` — A string ID of the location that is guaranteed to be unique within the session's limits and could be used as a "key" to store any supplementary data associated to this location.
140
+ * `index: number` — The index of the location in the navigation stack, starting with `0` for the initial location.
141
+
142
+ <!-- ## Subscribe to Location Changes -->
111
143
 
144
+ <!--
112
145
  One could use Redux'es standard [subscription mechanisms](https://redux.js.org/api/store#subscribelistener) to immediately get notified of current location changes.
113
146
 
114
147
  ```js
@@ -117,7 +150,7 @@ let currentLocation
117
150
  // Create a Redux store.
118
151
  const store = createStore(
119
152
  locationReducer, // Reducer function. For example, `locationReducer()`.
120
- applyMiddleware(...createMiddlewares(new BrowserSession()))
153
+ applyMiddleware(...createMiddlewares(new WebBrowserSession()))
121
154
  )
122
155
 
123
156
  // Subscribe to any potential Redux state changes.
@@ -125,204 +158,613 @@ const unsubscribe = store.subscribe(() => {
125
158
  const previousLocation = currentLocation
126
159
  currentLocation = store.getState() // In case of using `locationReducer()`.
127
160
  if (currentLocation !== previousLocation) {
161
+ // The first time is for the initial location.
162
+ // Next times will happen in case of navigation.
128
163
  console.log('Location has changed')
129
164
  }
130
165
  })
131
166
 
132
- // Initialize navigation.
133
- // Emitting a Redux action will trigger the listener.
134
- store.dispatch(Actions.init())
167
+ // Initialize navigation with an initial location.
168
+ //
169
+ // It will trigger the listener.
170
+ //
171
+ store.dispatch(Actions.init(window.location))
135
172
 
136
173
  // Stop listening to current location changes.
137
174
  unsubscribe()
138
175
  ```
176
+ -->
177
+
178
+ <!--
179
+ One could subscribe to location changes by calling `navigationStack.subscribe()`.
139
180
 
181
+ ```js
182
+ // Create a `NavigationStack` instance.
183
+ // It should be tied to a navigation "session".
184
+ const navigationStack = new NavigationStack(new WebBrowserSession())
185
+
186
+ // Subscribe to location changes.
187
+ // The first call happens for the initial location.
188
+ // Next calls will happen in case of navigation.
189
+ const unsubscribe = navigationStack.subscribe((location) => {
190
+ console.log('Current location', location)
191
+ })
192
+
193
+ // Navigate to a new location.
194
+ // It will trigger the listener.
195
+ navigationStack.push('/new-location')
196
+
197
+ // Stop listening to location changes.
198
+ unsubscribe()
199
+ ```
200
+ -->
201
+
202
+ <!--
140
203
  ## Why Redux?
141
204
 
142
205
  Why complicate things by providing "middlewares", "actions" and a "reducer" when it could be just a conventional API? That's because always knowing the "current location" means having to deal with "state management" in one way or another, and the simplest and most popular "state management" toolkit to date seems to be Redux.
143
206
 
144
207
  If it was just about dispatching the `Actions` then of course it wouldn't require any "state management". But it's the "get current location" piece that changes the whole picture. One could say that using Redux for such a simple task is an overkill but actually reinventing a wheel is what I would consider "overkill". It's like crafting your own screwdriver just because the one from Walmart feels too bulky.
208
+ -->
145
209
 
146
- ## Session
210
+ ## Scroll Position Restoration
147
211
 
148
- Navigation is performed within a given "session". A "session" sets the boundaries within a given navigation session exists, so that each user (and then each their browser tab) would have a separate navigation session. A specific "session" implementation maps `navigation-stack` concepts to their physical execution, such as web browser API. Three different "session" implementations are shipped with this package: `BrowserSession`, `ServerSession` and `MemorySession`.
212
+ Pass `maintainScrollPosition: true` option to keep track of scroll position on every page and then automatically restore it on "Back" or "Forward" navigation.
149
213
 
150
214
  ```js
151
- import {
152
- BrowserSession,
153
- ServerSession,
154
- MemorySession
155
- } from 'navigation-stack'
215
+ import { NavigationStack, WebBrowserSession } from 'navigation-stack'
216
+
217
+ // Create a `NavigationStack` instance with a `maintainScrollPosition: true` option.
218
+ const navigationStack = new NavigationStack(new WebBrowserSession(), {
219
+ maintainScrollPosition: true
220
+ })
221
+
222
+ //----------------------------------------------------------------------------------------
223
+
224
+ // Sets the initial location.
225
+ navigationStack.init()
226
+
227
+ // Render the initial location.
228
+ document.body.innerHTML = '<div> Initial Location </div>'
229
+
230
+ // As soon as a page has been rendered, without any delay, tell `NavigationStack` to restore
231
+ // a previously-saved scroll position, if there's any.
232
+ //
233
+ // This method must be called both for the initial location and any subsequent location.
234
+ //
235
+ navigationStack.locationRendered()
236
+
237
+ //----------------------------------------------------------------------------------------
238
+
239
+ // Set the `location` to be a new location.
240
+ //
241
+ // This also updates the URL in the web browser's address bar
242
+ // and adds a new entry in the web browser's navigation history.
243
+ //
244
+ navigationStack.push('/new-location')
245
+
246
+ // Render the new location.
247
+ document.body.innerHTML = '<div> New Location </div>'
156
248
 
157
- new BrowserSession()
158
- new ServerSession('/initial-location-url')
159
- new MemorySession('/initial-location-url')
249
+ // The new location is now rendered.
250
+ // Immediately after it has been rendered, call `.locationRenered()`.
251
+ // There's no scroll position to restore because it's not a previously-visited location.
252
+ navigationStack.locationRendered()
253
+
254
+ //----------------------------------------------------------------------------------------
255
+
256
+ // Set `location` "back" to the initial location.
257
+ //
258
+ // This also updates the URL in the web browser's address bar
259
+ // and repositions the "current location" pointer in the web browser's navigation history.
260
+ //
261
+ navigationStack.shift(-1)
262
+
263
+ // Render the initial location.
264
+ document.body.innerHTML = '<div> Initial Location </div>'
265
+
266
+ // The initial location is now rendered.
267
+ // Immediately after it has been rendered, call `.locationRenered()`.
268
+ // Restores the scroll position at the initial location.
269
+ navigationStack.locationRendered()
270
+
271
+ //----------------------------------------------------------------------------------------
272
+
273
+ // (optional)
274
+ // When the user is about to close the application,
275
+ // stop the `NavigationStack` and clean up any of its listeners.
276
+ // This is not required in a web browser because it cleans up all listeners
277
+ // automatically when closing a tab.
278
+ navigationStack.stop()
160
279
  ```
161
280
 
162
- - Use `BrowserSession` in a web browser. The navigation session is automatically limited to a given web browser tab and survives a page refresh.
163
- - Use `ServerSession` in server-side rendering. Create a separate `ServerSession` for each incoming HTTP request.
164
- - Use `MemorySession` in tests to mimick a `BrowserSession`. Create a separate `MemorySession` for each separate navigation session.
165
- - `MemorySession` supports saving and restoring its state, althrough there doesn't seem to be any real-world use for this feature. Yet, it exists. To enable state restoration, pass an optional second argument — an `options` object with properties:
166
- - `save(key: string, data: string)` — Saves `data` under the `key`.
167
- - `load(key: string)` — Loads the data stored under the `key`.
281
+ `NavigationStack` provides methods:
282
+
283
+ * `addScrollableContainer(key: string, element: Element)` — Use it in cases when it should restore not only the page scroll position but also the scroll position(s) of any other scrollable container(s). Returns a "remove scrollable container" function.
284
+ * `locationRendered()` Call it every time a different location has been rendered, including the initial location, without any delay, i.e. immediately after a different location has been rendered.
285
+
286
+ <details>
287
+ <summary>Using scroll position restoration feature without <code>NavigationStack</code></summary>
288
+
289
+ ######
290
+
291
+ To use the scroll position restoration feature independently of a `NavigationStack` instance (e.g. without it), create a `ScrollPositionRestoration` instance and pass a `session` argument to it.
292
+
293
+ ```js
294
+ import { WebBrowserSession } from 'navigation-stack'
295
+ import { ScrollPositionRestoration } from 'navigation-stack/scroll-position'
296
+
297
+ // Create a `ScrollPositionRestoration`.
298
+ const scrollPositionRestoration = new ScrollPositionRestoration(new WebBrowserSession())
299
+
300
+ //----------------------------------------------------------------------------------------
301
+
302
+ // If you decide to use `NavigationStack` or Redux-way `createMiddlewares()` for navigation,
303
+ // it should be tied to the same session.
304
+ //
305
+ // const navigationStack = new NavigationStack(session)
306
+ // navigationStack.init()
307
+ //
308
+ // Or, navigation could be performed by any other means such as using `window.history.pushState()`.
309
+ // The only requirement is for the "current location" object to have a `key` property
310
+ // which the standard `window.location` object doesn't provide.
311
+ //
312
+ window.history.replaceState({ key: '123' }, '', '/initial-location')
313
+
314
+ // Render the initial location.
315
+ document.body.innerHTML = '<div> Initial Location </div>'
316
+
317
+ // Immediately after a page has been rendered, without any delay,
318
+ // call `.locationRendered()` method with the "current location" object as the argument.
319
+ scrollPositionRestoration.locationRendered({ key: '123', pathname: '/initial-location' })
320
+
321
+ //----------------------------------------------------------------------------------------
322
+
323
+ // Navigate to a new location.
324
+ //
325
+ // For example, it could use `NavigationStack` for navigation.
326
+ //
327
+ // navigationStack.push('/new-location')
328
+ //
329
+ // Or, navigation could be performed by any other means such as using `window.history.pushState()`.
330
+ //
331
+ window.history.pushState({ key: '456' }, '', '/new-location')
332
+
333
+ // Render the new location.
334
+ document.body.innerHTML = '<div> New Location </div>'
335
+
336
+ // The new location is now rendered.
337
+ // Call `.locationRendered()` immediately after it has been rendered, i.e. without any delay.
338
+ // There's no scroll position to restore because it's not a previously-visited location.
339
+ // The "current location" object must have a `key`.
340
+ scrollPositionRestoration.locationRendered({ key: '456', pathname: '/new-location' })
341
+
342
+ //----------------------------------------------------------------------------------------
343
+
344
+ // Navigate "back" to the initial location.
345
+ //
346
+ // For example, it could use `NavigationStack` for navigation.
347
+ //
348
+ // navigationStack.shift(-1)
349
+ //
350
+ // Or, navigation could be performed by any other means such as using `window.history.go()`.
351
+ //
352
+ window.history.go(-1)
353
+
354
+ // Render the initial location.
355
+ document.body.innerHTML = '<div> Initial Location </div>'
356
+
357
+ // The initial location is now rendered.
358
+ // Call `.locationRendered()` immediately after it has been rendered, i.e. without any delay.
359
+ // It will restore the scroll position at the initial location.
360
+ // The "current location" object must have a `key`.
361
+ scrollPositionRestoration.locationRendered({ key: '123', pathname: '/initial-location' })
362
+
363
+ //----------------------------------------------------------------------------------------
364
+
365
+ // (optional)
366
+ // When the user is about to close the application,
367
+ // stop the `ScrollPositionRestoration` and clean up any of its listeners.
368
+ // This is not required in a web browser because it cleans up all listeners
369
+ // automatically when closing a tab.
370
+ scrollPositionRestoration.stop()
371
+
372
+ // (optional)
373
+ // In case of using `NavigationStack` for navigation,
374
+ // stop the `NavigationStack` and clean up any of its listeners.
375
+ //
376
+ // navigationStack.stop()
377
+ ```
378
+
379
+ `ScrollPositionRestoration` provides methods:
380
+
381
+ * `addScrollableContainer(key: string, element: Element)` — Use it in cases when it should restore not only the page scroll position but also the scroll position(s) of any other scrollable container(s). Returns a "remove scrollable container" function.
382
+ * `locationRendered(location)` — Call it every time a different location has been rendered, including the initial location, without any delay, i.e. immediately after a different location has been rendered. The location argument must have a `key`.
383
+ * `stop()` — Stops scroll position restoration and clears any listeners or timers.
384
+ </details>
168
385
 
169
386
  ## Base Path
170
387
 
388
+ <!--
171
389
  If the web application is hosted under a certain URL prefix, it should be specified in `createMiddlewares()` call as `basePath` parameter.
172
390
 
173
391
  ```js
174
- createMiddlewares(session, { basePath?: '/base/path' })
392
+ createMiddlewares(session, { basePath?: '/base-path' })
175
393
  ```
394
+ -->
176
395
 
177
- ## Location State Storage
178
-
179
- One could use `LocationDataStorage` in order to store location-specific data. For example, one could store scroll position of a page and then restore that scroll position when the user decides to navigate "Back" to the page.
396
+ If the web application is hosted under a certain URL prefix, it should be specified as a `basePath` parameter when creating a `NavigationStack` instance. This prefix will automatically be added to the URL in the web browser's address bar while the `location` object itself won't include it in the `pathname`.
180
397
 
181
398
  ```js
182
- import { BrowserSession, LocationDataStorage } from 'navigation-stack'
399
+ new NavigationStack(new WebBrowserSession(), { basePath: '/base-path' })
400
+ ```
183
401
 
184
- const session = new BrowserSession()
402
+ ## Session
185
403
 
186
- const storage = new LocationDataStorage(session, { namespace: 'my-namespace' })
404
+ A "session" ties `NavigationStack` to the environment it operates in, such as a web browser.
187
405
 
188
- const location = { pathname: '/abc' }
406
+ Three different "session" implementations are shipped with this package:
189
407
 
190
- storage.set(location, 'key', 123)
191
- storage.get(location, 'key') === 123
192
- ```
408
+ - Use `WebBrowserSession` in a web browser. Such session survives a page refresh and is automatically destroyed when the web browser tab gets closed.
409
+ - Use `ServerSideRenderSession` in server-side rendering. Create a separate session for each incoming HTTP request. Initialize it with a relative URL of the HTTP request. If, during server-side render, the application code attempts to navigate to another location, it will throw a `ServerSideNavigationError` with a `location` property in it.
410
+ - Use `InMemorySession` in tests to mimick a `WebBrowserSession`. Create a separate session for each separate navigation session. Initialize it with a relative URL or a location object.
411
+
412
+ <details>
413
+ <summary>See <code>ServerSideRenderSession</code> example</summary>
414
+
415
+ ######
416
+
417
+ ```js
418
+ const navigationStack = new NavigationStack(new ServerSideRenderSession())
419
+
420
+ navigationStack.subscribe((location) => {
421
+ console.log('Current location', location)
422
+ })
193
423
 
194
- `LocationDataStorage` doesn't provide any guarantees about actually storing the data: if it encounters any errors in the process, it simply ignores them. This simplifies the API in a way that the application doesn't have to wrap `.get()`/`.set()` calls in a `try/catch` block. And judging by the nature of location-specific data, that type of data is inherently non-essential and rather "nice-to-have".
424
+ // Sets the initial location.
425
+ // Triggers the subscription listener.
426
+ navigationStack.init('/initial-location')
427
+
428
+ // Navigates to a new location.
429
+ // Throws `ServerSideNavigationError` with a `location` property.
430
+ navigationStack.push('/new-location')
431
+ ```
432
+ </details>
195
433
 
196
- One might ask: Why use `LocationDataStorage` when one could simply store the data in a usual variable? The answer is that a usual variable doesn't survive if the user decides to refresh the page. But the entire navigation history does survive because that's how web browsers work. So if the user decides to go "Back" after refreshing the current page, the data associated to that previous location would already be lost and can't be recovered. In contrast, when using a `LocationDataStorage` with a `BrowserSession`, the stored data does survive a page refresh, which feels more consistent and coherent with the persistence behavior of the navigation history itself.
197
434
 
198
- ## Get Notified Before Location Changes
435
+ <details>
436
+ <summary>See <code>InMemorySession</code> example</summary>
199
437
 
200
- One could subscribe to "before change" events of the current location by calling `addBeforeLocationChangeListener()` exported function. The listener function will be called before the `location` object in Redux state is updated. This might be a suitable opportunity to save location-specific state such as the scroll position.
438
+ ######
201
439
 
202
440
  ```js
203
- import { createStore, applyMiddleware } from 'redux'
441
+ const navigationStack = new NavigationStack(new InMemorySession())
442
+
443
+ navigationStack.subscribe((location) => {
444
+ console.log('Current location', location)
445
+ })
446
+
447
+ // Sets the initial location.
448
+ // Triggers the subscription listener.
449
+ navigationStack.init('/initial-location')
450
+
451
+ // Navigates to a new location.
452
+ // Triggers the subscription listener.
453
+ navigationStack.push('/new-location')
454
+ ```
455
+ </details>
456
+
457
+ ######
458
+
459
+ Every "session" has a unique `key`.
460
+
461
+ Once created, a "session" is simply passed to the `NavigationStack` constructor and then you don't have to deal with it anymore — `NavigationStack` will pull all the strings for you.
462
+
463
+ However, if someone prefers to completely bypass `NavigationStack` and interact with a "session" object directly, they could do so.
464
+
465
+ <details>
466
+ <summary>See "session" API</summary>
467
+
468
+ ######
469
+
470
+ * `key: string` — A unique ID of the session.
471
+ * `subscribe(listener: (location) => {}): () => {}` — Subscribes to location changes, including setting the initial location. Returns an "unsubscribe" function. The `location` argument of the listener function is an "extended" location object having additional properties:
472
+ * `operation: string` — The type of navigation that led to the location.
473
+ * `INIT` in case of the initial location before any navigation has taken place.
474
+ * `SHIFT` when the user performs a "Back" or "Forward" navigation, or after a `.shift()` navigation which is essentially a "back or forward navigation".
475
+ * `PUSH` in case of a `.push()` navigation, i.e. "normal navigation via a hyperlink".
476
+ * `REPLACE` in case of a `.replace()` navigation, i.e. "redirect".
477
+ * `delta: number` — the difference between the `index` of the current location and the `index` of the previous location.
478
+ * `0` for the initial location before any navigation has taken place.
479
+ * `1` after a `.push()` navigation, i.e. "normal navigation via a hyperlink".
480
+ * `0` after a `.replace()` navigation, i.e. "redirect".
481
+ * `delta: number` after a `.shift(delta)` navigation, i.e. "back or forward navigation".
482
+ * `-1` after the user clicks a "Back" button in their web browser.
483
+ * `1` after the user clicks a "Forward" button in their web browser.
484
+ <!-- * `getInitialLocation(): object?` — Returns the initial location, if the session can get it from somewhere. For example, in a web browser, the initial location can be read from `window.location`. In other environments, such as server side, the initial location can't be read from anywhere. -->
485
+ * `start(initialLocation?: object)` — Starts the session. The `initialLocation` argument is optional when the session can read it from somewhere. For example, `WebBrowserSession` can read `initialLocation` from `window.location`.
486
+ * `stop()` — Stops the session. Cleans up any listeners, etc.
487
+ * `navigate(operation: string, location: object)` — Navigates to a `location` using either `"PUSH"` or `"REPLACE"` operation. The `location` argument should be a result of calling `parseInputLocation()` function.
488
+ * `shift(delta: number)` — Navigates "back" or "forward" by skipping a specified count of pages. Negative `delta` skips backwards, positive `delta` skips forward.
489
+ </details>
204
490
 
491
+ ## Utility
492
+
493
+ This package exports a few utility functions for transforming locations.
494
+
495
+ ```js
205
496
  import {
206
- createMiddlewares,
207
- locationReducer,
208
- Actions,
209
- BrowserSession,
210
- addBeforeLocationChangeListener
497
+ getLocationUrl,
498
+ parseLocationUrl,
499
+ parseInputLocation,
500
+ addBasePath,
501
+ removeBasePath
211
502
  } from 'navigation-stack'
212
503
 
213
- const session = new BrowserSession()
504
+ // The following two are "mutually inverse functions":
505
+ // one maps a `location` object to a URL string
506
+ // and the other maps a URL string to a `location` object.
214
507
 
215
- // Create a Redux store.
216
- const store = createStore(
217
- locationReducer, // Reducer function. For example, `locationReducer()`.
218
- applyMiddleware(...createMiddlewares(session))
219
- )
508
+ // Converts a location object to a location URL.
509
+ getLocationUrl({ pathname: '/abc', search: '?d=e' }) === '/abc?d=e'
220
510
 
221
- // Subscribe to "before location change" events.
222
- const removeBeforeLocationChangeListener = addBeforeLocationChangeListener(
223
- session,
224
- (newLocation) => {
225
- console.log(newLocation)
226
- }
227
- );
511
+ // Parses a location URL to a location object.
512
+ // If there're no query parameters, `query` property will be an empty object.
513
+ parseLocationUrl('/abc?d=e') === {
514
+ pathname: '/abc',
515
+ search: '?d=e',
516
+ query: { d: 'e' },
517
+ hash: ''
518
+ }
519
+
520
+ // The following function parses a non-strict location object to a strict one.
521
+ // It also parses a location URL to a location object.
522
+
523
+ parseInputLocation({ pathname: '/abc', search: '?d=e' }) === {
524
+ pathname: '/abc',
525
+ search: '?d=e',
526
+ query: { d: 'e' },
527
+ hash: ''
528
+ }
529
+
530
+ parseInputLocation('/abc?d=e') === {
531
+ pathname: '/abc',
532
+ search: '?d=e',
533
+ query: { d: 'e' },
534
+ hash: ''
535
+ }
228
536
 
229
- // Initialize navigation.
230
- // This will not trigger the listener because it's not a navigation.
231
- store.dispatch(Actions.init())
537
+ // The following two functions can be used to add base path to a location
538
+ // or to remove it from it.
232
539
 
233
- // This navigation event will trigger the listener.
234
- // `newLocation.action` will be "PUSH".
235
- store.dispatch(Actions.push('/new/location'))
540
+ // Adds `basePath` to a location object or a location URL.
541
+ addBasePath('/abc', '/base-path') === '/base-path/abc'
542
+ addBasePath({ pathname: '/abc' }, '/base-path') === { pathname: '/base-path/abc' }
236
543
 
237
- // Unsubscribe from "before location change" events.
238
- removeBeforeLocationChangeListener()
544
+ // Removes `basePath` from a location object or a location URL.
545
+ // If `basePath` is not present in location, it won't do anything.
546
+ removeBasePath('/base-path/abc', '/base-path') === '/abc';
547
+ removeBasePath({ pathname: '/base-path/abc' }, '/base-path') === { pathname: '/abc' }
239
548
  ```
240
549
 
241
550
  ## Block Navigation
242
551
 
243
- `navigation-stack` provides the ability to block navigation. Call `addNavigationBlocker()` exported function to set up a navigation blocker.
552
+ `navigation-stack` provides the ability to block navigation. Call `addNavigationBlocker()` function to set up a "navigation blocker".
244
553
 
245
554
  ```js
246
- import { createStore, applyMiddleware } from 'redux'
247
-
248
555
  import {
249
- createMiddlewares,
250
- locationReducer,
251
- Actions,
252
- BrowserSession,
556
+ NavigationStack,
557
+ WebBrowserSession,
253
558
  addNavigationBlocker
254
559
  } from 'navigation-stack'
255
560
 
256
- const session = new BrowserSession()
561
+ // Create a session.
562
+ const session = new WebBrowserSession()
257
563
 
258
- // Create a Redux store.
259
- const store = createStore(
260
- locationReducer, // Reducer function. For example, `locationReducer()`.
261
- applyMiddleware(...createMiddlewares(session))
262
- )
263
-
264
- // Initialize navigation.
265
- store.dispatch(Actions.init())
564
+ // Create a `NavigationStack` instance.
565
+ const navigationStack = new NavigationStack(session)
266
566
 
267
- // Add navigation blocker.
567
+ // Add a navigation blocker.
568
+ // It should be tied to the same "session".
268
569
  const removeNavigationBlocker = addNavigationBlocker(
269
570
  session,
270
571
  (newLocation) => {
271
- // Returning `true` means "block this navigation".
572
+ // Returning `true` means "this navigation should be blocked".
272
573
  return true
273
574
  }
274
575
  );
275
576
 
276
- // This navigation won't be performed.
277
- store.dispatch(Actions.push('/new/location'))
577
+ // Because the navigation is blocked, current location will not change here.
578
+ //
579
+ // The URL in the web browser's address bar will stay the same
580
+ // and no new entries will be added in the web browser's navigation history.
581
+ //
582
+ navigationStack.push('/new-location')
278
583
 
279
584
  // Remove the navigation blocker.
280
585
  removeNavigationBlocker()
281
586
 
282
- // This navigation now will be performed.
283
- store.dispatch(Actions.push('/new/location'))
587
+ // With the blocker removed, current location will be set to a new one.
588
+ //
589
+ // This also updates the URL in the web browser's address bar
590
+ // and adds a new entry in the web browser's navigation history.
591
+ //
592
+ navigationStack.push('/new-location')
284
593
  ```
285
594
 
286
595
  Navigation blocker should be a function that receives a `newLocation` argument and could be "synchronous" or "asynchronous" (i.e. return a `Promise`, aka `async`/`await`).
287
596
 
288
- The `newLocation` argument of a blocker function won't necessarily have a `key` or `index` property but other properties are present.
597
+ The `newLocation` argument of a blocker function might not necessarily have a `key` or `index` property but other properties are present.
289
598
 
290
- Navigation blockers fire both when navigating from one page to another and when closing the current browser tab. In the latter case, `newLocation` argument will be `null`, the function can't return a `Promise`, and returning `true` will cause the web browser to show a confirmation modal with a non-customizable browser-specific text.
599
+ Navigation blockers fire both when navigating from one page to another and when closing the current browser tab. In the latter case, `newLocation` argument will be `null`, and also the blocker function can't return a `Promise` (because it won't wait), and returning `true` from it will cause the web browser will to show a confirmation modal with a non-customizable generic browser-specific text like "Leave site? Changes you made might not be saved".
291
600
 
292
- ## Utility
601
+ ## Data Storage
602
+
603
+ One could use `DataStorage` to store any kind of application-specific data in a given "session". The data will exist as long as the "session" exists.
604
+
605
+ Different types of data could be stored under a different `key`.
606
+
607
+ If each different location should have it's own data stored under the same `key`, one could use `LocationDataStorage` instead of just `DataStorage`. For example, one could store scroll position for each different page to be able to restore it when the user decides to navigate "Back" to that page. By the way, that's precisely what `ScrollPositionRestoration` does.
293
608
 
294
- This package exports a couple of utility functions.
609
+ `DataStorage` constructor receives a `session` argument and a `namespace` parameter. The `namespace` just gets prepended to every `key`. The idea is that your `namespace` must not clash with anyone else's `namespace` who might potentially use the same `session` to store their own data.
295
610
 
296
611
  ```js
612
+ import { WebBrowserSession } from 'navigation-stack'
613
+ import { DataStorage, LocationDataStorage } from 'navigation-stack/data-storage'
614
+
615
+ const session = new WebBrowserSession()
616
+
617
+ // `DataStorage` example
618
+
619
+ const dataStorage = new DataStorage(session, { namespace: 'my-namespace' })
620
+
621
+ dataStorage.set('key', 123)
622
+ dataStorage.get('key') === 123
623
+
624
+ // `LocationDataStorage` example
625
+
626
+ const locationDataStorage = new LocationDataStorage(session, { namespace: 'my-namespace' })
627
+
628
+ const location = { pathname: '/abc' }
629
+
630
+ locationDataStorage.set(location, 'key', 123)
631
+ locationDataStorage.get(location, 'key') === 123
632
+ ```
633
+
634
+ `DataStorage` or `LocationDataStorage` don't provide any guarantees about actually storing the data: if it encounters any errors in the process, it simply ignores them. This simplifies the API in a way that the application doesn't have to wrap `.get()`/`.set()` calls in a `try/catch` block. And judging by the nature of location-specific data, that type of data is inherently non-essential and rather "nice-to-have".
635
+
636
+ One might ask: Why use `DataStorage` or `LocationDataStorage` when one could simply store the data in a usual variable? The answer is that a usual variable doesn't survive if the user decides to refresh the page. But the entire navigation history does survive because that's how web browsers work. So if the user decides to go "Back" after refreshing the current page, the data associated to that previous location would already be lost and can't be recovered. In contrast, when using a `DataStorage` or `LocationDataStorage` with a `WebBrowserSession`, the stored data does survive a page refresh, which feels more consistent and coherent with the persistence behavior of the navigation history itself.
637
+
638
+ ## Redux
639
+
640
+ Under the hood, `navigation-stack` uses [`redux`](https://redux.js.org/). Why? For no particular reason. The original [`farce`](http://npmjs.com/package/farce) package was published in September 2016, and by that time `redux` had still been a hot topic since [July 2025](https://www.youtube.com/watch?v=xsSnOQynTHs). This package could most certainly be rewritten without using `redux`, it's just that there seems to be no need to do that.
641
+
642
+ So since `navigation-stack` already implements all that `redux` stuff internally, such as "middlewares" or "actions", why not export it for public usage? Maybe there're still some `redux` fans out there.
643
+
644
+ Using `navigation-stack` `redux`-way is equivalent to using it the conventional way via `NavigationStack` class. `navigation-stack` exports "middlewares", "actions" and a "reducer" that could be used in conjunction with `redux` or any other `redux`-compatible package (e.g. [`mini-redux`](https://www.npmjs.com/package/mini-redux)).
645
+
646
+ <details>
647
+ <summary>See <code>redux</code>-style API</summary>
648
+
649
+ ######
650
+
651
+ Start by creating a Redux "store" with `navigation-stack` middlewares.
652
+
653
+ ```js
654
+ import { createStore, applyMiddleware } from 'redux';
655
+
297
656
  import {
298
- addBasePath,
299
- removeBasePath,
300
- getLocationUrl,
301
- parseLocationUrl
302
- } from 'navigation-stack'
657
+ createMiddlewares,
658
+ locationReducer,
659
+ Actions,
660
+ WebBrowserSession,
661
+ } from 'navigation-stack';
303
662
 
304
- // Parses a location URL to a location object.
305
- // If there're no query parameters, `query` property will be an empty object.
306
- parseLocationUrl('/abc?d=e') === {
307
- pathname: '/abc',
308
- search: '?d=e',
309
- query: { d: 'e' },
310
- hash: ''
311
- }
663
+ // Create a Redux store.
664
+ const store = createStore(
665
+ // Reducer function. For example, `locationReducer()`.
666
+ locationReducer,
667
+ // It should be tied to a navigation "session".
668
+ applyMiddleware(...createMiddlewares(new WebBrowserSession())),
669
+ );
670
+ ```
312
671
 
313
- // Converts a location object to a location URL.
314
- getLocationUrl({ pathname: '/abc', search: '?d=e', hash: '' }) === '/abc?d=e'
672
+ Next, set the initial location. Normally, `NavigationStack` class API automatically performs this step for a developer. When using Redux API though, it doesn't do that and the developer has to do it themself.
315
673
 
316
- // Adds `basePath` to a location object or a location URL.
317
- addBasePath('/abc', '/base-path') === '/base-path/abc'
318
- addBasePath({ pathname: '/abc' }, '/base-path') === { pathname: '/base-path/abc' }
674
+ ```js
675
+ // Sets the initial `location`.
676
+ //
677
+ // Accepts either a relative URL string or a location object.
678
+ //
679
+ // The initial location argument could be omitted for `WebBrowserSession`
680
+ // because it can read it by itself from `window.location`.
681
+ // Other types of session such as `InMemorySession` or `ServerSideRenderSession`
682
+ // don't have an initial location and require the initial location argument
683
+ // to be specified explicitly when creating an `Actions.init(initialLocation)` action.
684
+ //
685
+ store.dispatch(Actions.init());
686
+ ```
319
687
 
320
- // Removes `basePath` from a location object or a location URL.
321
- // If `basePath` is not present in location, it won't do anything.
322
- removeBasePath('/base-path/abc', '/base-path') === '/abc';
323
- removeBasePath({ pathname: '/base-path/abc' }, '/base-path') === { pathname: '/abc' }
688
+ Then subscribe to location changes. One could use Redux'es standard [subscription mechanisms](https://redux.js.org/api/store#subscribelistener) to immediately get notified of current location changes.
689
+
690
+ ```js
691
+ let currentLocation;
692
+
693
+ // Create a Redux store.
694
+ const store = createStore(
695
+ locationReducer, // Reducer function. For example, `locationReducer()`.
696
+ applyMiddleware(...createMiddlewares(new WebBrowserSession())),
697
+ );
698
+
699
+ // Subscribe to any potential Redux state changes.
700
+ const unsubscribe = store.subscribe(() => {
701
+ const previousLocation = currentLocation;
702
+ currentLocation = store.getState(); // In case of using `locationReducer()`.
703
+ if (currentLocation !== previousLocation) {
704
+ console.log('Current location', currentLocation);
705
+ }
706
+ });
707
+ ```
708
+
709
+ Now ready to perform navigation actions by dispatching any of the available `Actions`.
710
+
711
+ ```js
712
+ // Sets the `location` to be a new location.
713
+ //
714
+ // Also updates the URL in the web browser's address bar.
715
+ //
716
+ // Also adds a new entry in the web browser's navigation history.
717
+ //
718
+ store.dispatch(Actions.push('/new-location'));
719
+
720
+ // Sets the `location` to be a new location.
721
+ //
722
+ // Also updates the URL in the web browser's address bar.
723
+ //
724
+ // Does not add a new entry in the web browser's navigation history
725
+ // which is the only difference between this and `Actions.push()`.
726
+ //
727
+ store.dispatch(Actions.replace('/new-location'));
728
+
729
+ // Sets the `location` to be a previous one (if there is one).
730
+ // One could think of it as an equivalent of clicking a "Back" button in a web browser.
731
+ //
732
+ // Also updates the URL in the web browser's address bar.
733
+ //
734
+ // Also shifts the current position in the web browser's navigation history.
735
+ //
736
+ store.dispatch(Actions.shift(-1));
737
+
738
+ // Sets the `location` to be a next one (if there is one).
739
+ // One could think of it as an equivalent of clicking a "Forward" button in a web browser.
740
+ //
741
+ // Also updates the URL in the web browser's address bar.
742
+ //
743
+ // Also shifts the current position in the web browser's navigation history.
744
+ //
745
+ store.dispatch(Actions.shift(1));
324
746
  ```
325
747
 
748
+ To get the current location:
749
+
750
+ ```js
751
+ // When `locationReducer()` is used, `store.getState()` returns the current location.
752
+ const location = store.getState();
753
+ console.log(location);
754
+ ```
755
+
756
+ (optional) After the user is done using the app, stop the session and clean up any listeners.
757
+
758
+ ```js
759
+ // (optional)
760
+ // When the user closes the application,
761
+ // stop the session and clean up any listeners.
762
+ // There's no need to do this in a web browser.
763
+ unsubscribe();
764
+ store.dispatch(Actions.stop());
765
+ ```
766
+ </details>
767
+
326
768
  ## Development
327
769
 
328
770
  Clone the repository. Then:
@@ -332,3 +774,9 @@ yarn
332
774
  yarn format
333
775
  yarn test
334
776
  ```
777
+
778
+ It runs tests in two web browsers (for no particular reason) — Chrome and Firefox (configurable in `karma.conf.cjs`). When running `yarn test`, it opens Chome and Firefox browser windows. Don't unfocus those windows, otherwise the tests won't finish.
779
+
780
+ ## GitHub
781
+
782
+ On March 9th, 2020, GitHub, Inc. silently [banned](https://medium.com/@catamphetamine/how-github-blocked-me-and-all-my-libraries-c32c61f061d3) my account (erasing all my repos, issues and comments, even in my employer's private repos) without any notice or explanation. Because of that, all source codes had to be promptly moved to GitLab. The [GitHub repo](https://github.com/catamphetamine/navigation-stack) is now only used as a backup (you can star the repo there too), and the primary repo is now the [GitLab one](https://gitlab.com/catamphetamine/navigation-stack). Issues can be reported in any repo.