@dharmax/state-router 3.2.1 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/router.d.ts CHANGED
@@ -1,28 +1,16 @@
1
1
  type RouteHandler = (...args: string[]) => void;
2
2
  export type RoutingMode = 'history' | 'hash';
3
3
  export declare class Router {
4
- private mode;
5
- private routes;
6
- private root;
7
- private rootCompare;
8
- private baseLocation;
4
+ #private;
9
5
  staticFilters: ((url: string) => boolean)[];
10
- private isListening;
11
- private bound;
12
- private notFoundHandler?;
13
- private decodeParams;
14
6
  constructor();
15
- private cleanPathString;
16
- private clearQuery;
17
- private isStaticFile;
18
7
  resetRoot(root: string): void;
8
+ setMode(mode: RoutingMode): void;
19
9
  getLocation(): string;
20
10
  add(pattern: RegExp | string | RouteHandler, handler?: RouteHandler): Router;
21
11
  onNotFound(handler: (path: string) => void): Router;
22
12
  setDecodeParams(decode: boolean): Router;
23
13
  getQueryParams(search?: string): Record<string, string>;
24
- private isBrowser;
25
- private compilePattern;
26
14
  /**
27
15
  *
28
16
  * @param location
package/dist/router.js CHANGED
@@ -1,56 +1,59 @@
1
1
  export class Router {
2
- mode = 'hash';
3
- routes = [];
4
- root = '/';
5
- rootCompare = '';
6
- baseLocation = null;
2
+ #mode = 'hash';
3
+ #routes = [];
4
+ #root = '/';
5
+ #rootCompare = '';
6
+ #baseLocation = null;
7
7
  staticFilters = [];
8
- isListening = false;
9
- bound = {};
10
- notFoundHandler;
11
- decodeParams = false;
8
+ #isListening = false;
9
+ #bound = {};
10
+ #notFoundHandler;
11
+ #decodeParams = false;
12
12
  constructor() {
13
13
  this.staticFilters.push(url => {
14
14
  const staticFileExtensions = ['.json', '.css', '.js', '.png', '.jpg', '.svg', '.webp', '.md', '.ejs', '.jsm', '.txt'];
15
15
  return staticFileExtensions.some(ext => url.endsWith(ext));
16
16
  });
17
17
  }
18
- cleanPathString(path) {
18
+ #cleanPathString(path) {
19
19
  path = path.replace(/\/$/, '').replace(/^\//, '');
20
20
  return path = path.replace(/#{2,}/g, '#');
21
21
  }
22
- clearQuery(url) {
22
+ #clearQuery(url) {
23
23
  const [path, query] = url.split('?');
24
24
  if (!query)
25
25
  return path;
26
26
  const [_, hash] = query.split('#');
27
27
  return hash ? `${path}#${hash}` : path;
28
28
  }
29
- isStaticFile(url) {
30
- return (this.staticFilters || []).some(filter => filter(url));
29
+ #isStaticFile(url) {
30
+ return this.staticFilters.some(filter => filter(url));
31
31
  }
32
32
  resetRoot(root) {
33
- const cleaned = this.cleanPathString(root);
34
- this.root = '/' + cleaned + '/';
35
- this.rootCompare = cleaned ? cleaned + '/' : '';
33
+ const cleaned = this.#cleanPathString(root);
34
+ this.#root = '/' + cleaned + '/';
35
+ this.#rootCompare = cleaned ? cleaned + '/' : '';
36
+ }
37
+ setMode(mode) {
38
+ this.#mode = mode;
36
39
  }
37
40
  getLocation() {
38
- if (!this.isBrowser())
41
+ if (!this.#isBrowser())
39
42
  return '';
40
- if (this.mode === 'history') {
43
+ if (this.#mode === 'history') {
41
44
  let fragment = decodeURI(window.location.pathname + window.location.search);
42
- fragment = this.clearQuery(fragment);
45
+ fragment = this.#clearQuery(fragment);
43
46
  // strip leading slash for comparison convenience
44
47
  fragment = fragment.replace(/^\//, '');
45
- if (this.root !== '/' && this.rootCompare && fragment.startsWith(this.rootCompare)) {
46
- fragment = fragment.slice(this.rootCompare.length);
48
+ if (this.#root !== '/' && this.#rootCompare && fragment.startsWith(this.#rootCompare)) {
49
+ fragment = fragment.slice(this.#rootCompare.length);
47
50
  }
48
- fragment = this.cleanPathString(fragment);
51
+ fragment = this.#cleanPathString(fragment);
49
52
  return fragment;
50
53
  }
51
54
  else {
52
55
  const match = window.location.href.match(/#(.*)$/);
53
- return match ? this.clearQuery(match[1]) : '';
56
+ return match ? this.#clearQuery(match[1]) : '';
54
57
  }
55
58
  }
56
59
  add(pattern, handler) {
@@ -60,23 +63,23 @@ export class Router {
60
63
  pattern = /^.*$/; // Match any path
61
64
  }
62
65
  else if (typeof pattern === 'string') {
63
- const compiled = this.compilePattern(pattern);
66
+ const compiled = this.#compilePattern(pattern);
64
67
  pattern = compiled.regex;
65
68
  paramNames = compiled.paramNames;
66
69
  }
67
- this.routes.push({ pattern: pattern, handler: handler, paramNames });
70
+ this.#routes.push({ pattern: pattern, handler: handler, paramNames });
68
71
  return this;
69
72
  }
70
73
  onNotFound(handler) {
71
- this.notFoundHandler = handler;
74
+ this.#notFoundHandler = handler;
72
75
  return this;
73
76
  }
74
77
  setDecodeParams(decode) {
75
- this.decodeParams = decode;
78
+ this.#decodeParams = decode;
76
79
  return this;
77
80
  }
78
81
  getQueryParams(search) {
79
- if (!this.isBrowser() && !search)
82
+ if (!this.#isBrowser() && !search)
80
83
  return {};
81
84
  const qs = typeof search === 'string' ? search : window.location.search || '';
82
85
  const usp = new URLSearchParams(qs);
@@ -84,10 +87,10 @@ export class Router {
84
87
  usp.forEach((v, k) => { obj[k] = v; });
85
88
  return obj;
86
89
  }
87
- isBrowser() {
90
+ #isBrowser() {
88
91
  return typeof window !== 'undefined' && typeof document !== 'undefined';
89
92
  }
90
- compilePattern(pattern) {
93
+ #compilePattern(pattern) {
91
94
  // normalize leading slash to align with cleanPathString behavior
92
95
  const normalized = pattern.replace(/^\//, '');
93
96
  const paramNames = [];
@@ -107,14 +110,14 @@ export class Router {
107
110
  */
108
111
  handleChange(location) {
109
112
  const path = (location ?? this.getLocation()) || '';
110
- if (this.isStaticFile(path))
113
+ if (this.#isStaticFile(path))
111
114
  return false; // Bypass routing for static files
112
- for (const route of this.routes) {
115
+ for (const route of this.#routes) {
113
116
  const match = route.pattern ? path.match(route.pattern) : null;
114
117
  if (match) {
115
118
  match.shift(); // Remove the full match element
116
119
  const queryParams = this.getQueryParams();
117
- const captures = this.decodeParams ? match.map(v => safeDecode(v)) : match;
120
+ const captures = this.#decodeParams ? match.map(v => safeDecode(v)) : match;
118
121
  let params;
119
122
  if (route.paramNames && route.paramNames.length) {
120
123
  params = {};
@@ -124,8 +127,8 @@ export class Router {
124
127
  return true;
125
128
  }
126
129
  }
127
- if (this.notFoundHandler) {
128
- this.notFoundHandler(path);
130
+ if (this.#notFoundHandler) {
131
+ this.#notFoundHandler(path);
129
132
  return true;
130
133
  }
131
134
  if (path)
@@ -133,20 +136,20 @@ export class Router {
133
136
  return false;
134
137
  }
135
138
  listen(mode = 'hash') {
136
- if (!this.isBrowser())
139
+ if (!this.#isBrowser())
137
140
  return;
138
141
  // avoid duplicate listeners
139
- if (this.isListening)
142
+ if (this.#isListening)
140
143
  this.unlisten();
141
144
  const self = this;
142
- this.mode = mode;
145
+ this.#mode = mode;
143
146
  const handler = (path) => {
144
147
  const p = path || location.href.split('#')[0];
145
- if (self.isStaticFile(p))
148
+ if (self.#isStaticFile(p))
146
149
  return;
147
150
  const currentLocation = self.getLocation();
148
- if (self.baseLocation !== currentLocation) {
149
- self.baseLocation = currentLocation;
151
+ if (self.#baseLocation !== currentLocation) {
152
+ self.#baseLocation = currentLocation;
150
153
  self.handleChange(currentLocation);
151
154
  }
152
155
  };
@@ -160,9 +163,9 @@ export class Router {
160
163
  return;
161
164
  }
162
165
  const target = event.target;
163
- if (!target || !('closest' in target))
166
+ if (!target)
164
167
  return;
165
- const anchor = target.closest?.('a');
168
+ const anchor = target.closest('a');
166
169
  if (!anchor)
167
170
  return;
168
171
  if (event.type === 'keydown') {
@@ -187,7 +190,7 @@ export class Router {
187
190
  if (rel && /\bnoreferrer\b/i.test(rel))
188
191
  return;
189
192
  const pathWithQuery = url.pathname + (url.search || '');
190
- if (self.isStaticFile(pathWithQuery) || self.isStaticFile(url.pathname))
193
+ if (self.#isStaticFile(pathWithQuery) || self.#isStaticFile(url.pathname))
191
194
  return;
192
195
  event.preventDefault();
193
196
  history.pushState({ path: pathWithQuery }, '', pathWithQuery);
@@ -195,53 +198,53 @@ export class Router {
195
198
  };
196
199
  switch (mode) {
197
200
  case 'hash':
198
- this.bound.hashchange = () => handler();
199
- window.addEventListener('hashchange', this.bound.hashchange);
201
+ this.#bound.hashchange = () => handler();
202
+ window.addEventListener('hashchange', this.#bound.hashchange);
200
203
  break;
201
204
  case 'history':
202
- this.bound.popstate = (event) => handler(event.state?.path);
203
- this.bound.click = handleInternalNavigation;
204
- this.bound.keydown = (event) => handleInternalNavigation(event);
205
- window.addEventListener('popstate', this.bound.popstate);
206
- document.addEventListener('click', this.bound.click);
207
- document.addEventListener('keydown', this.bound.keydown);
205
+ this.#bound.popstate = (event) => handler(this.getLocation());
206
+ this.#bound.click = handleInternalNavigation;
207
+ this.#bound.keydown = (event) => handleInternalNavigation(event);
208
+ window.addEventListener('popstate', this.#bound.popstate);
209
+ document.addEventListener('click', this.#bound.click);
210
+ document.addEventListener('keydown', this.#bound.keydown);
208
211
  break;
209
212
  }
210
- this.isListening = true;
213
+ this.#isListening = true;
211
214
  handler();
212
215
  }
213
216
  unlisten() {
214
- if (!this.isBrowser() || !this.isListening)
217
+ if (!this.#isBrowser() || !this.#isListening)
215
218
  return;
216
- switch (this.mode) {
219
+ switch (this.#mode) {
217
220
  case 'hash':
218
- if (this.bound.hashchange)
219
- window.removeEventListener('hashchange', this.bound.hashchange);
221
+ if (this.#bound.hashchange)
222
+ window.removeEventListener('hashchange', this.#bound.hashchange);
220
223
  break;
221
224
  case 'history':
222
- if (this.bound.popstate)
223
- window.removeEventListener('popstate', this.bound.popstate);
224
- if (this.bound.click)
225
- document.removeEventListener('click', this.bound.click);
226
- if (this.bound.keydown)
227
- document.removeEventListener('keydown', this.bound.keydown);
225
+ if (this.#bound.popstate)
226
+ window.removeEventListener('popstate', this.#bound.popstate);
227
+ if (this.#bound.click)
228
+ document.removeEventListener('click', this.#bound.click);
229
+ if (this.#bound.keydown)
230
+ document.removeEventListener('keydown', this.#bound.keydown);
228
231
  break;
229
232
  }
230
- this.bound = {};
231
- this.isListening = false;
233
+ this.#bound = {};
234
+ this.#isListening = false;
232
235
  }
233
236
  navigate(path = '', opts) {
234
- if (!this.isBrowser())
237
+ if (!this.#isBrowser())
235
238
  return false;
236
- if (this.mode === 'history') {
237
- const url = this.root + this.cleanPathString(path);
239
+ if (this.#mode === 'history') {
240
+ const url = this.#root + this.#cleanPathString(path);
238
241
  if (opts?.replace)
239
242
  history.replaceState(null, '', url);
240
243
  else
241
244
  history.pushState(null, '', url);
242
245
  }
243
246
  else
244
- window.location.hash = this.cleanPathString(path);
247
+ window.location.hash = this.#cleanPathString(path);
245
248
  return this.handleChange();
246
249
  }
247
250
  replace(path = '') {
@@ -6,33 +6,27 @@ export type ApplicationState = {
6
6
  pageName: string;
7
7
  route: RegExp | string;
8
8
  mode?: string | string[];
9
+ onExit?: (context?: any) => void | Promise<void>;
9
10
  };
10
11
  export type ChangeAuthority = (state: ApplicationState) => Promise<boolean>;
11
12
  export declare class StateManager {
13
+ #private;
12
14
  private mode;
13
- private allStates;
14
- private appState;
15
- private previousState;
16
- private stateContext;
17
15
  static dispatcher: import("@dharmax/pubsub").PubSub;
18
- private changeAuthorities;
19
- private router;
20
- private beforeChangeHandlers;
21
- private afterChangeHandlers;
22
16
  constructor(mode?: RoutingMode, autostart?: boolean, routerInstance?: RouterType);
23
17
  start(): void;
24
18
  stop(): void;
25
19
  onChange(handler: (event: PubSubEvent, data: any) => void): IPubSubHandle;
26
20
  registerChangeAuthority(authorityCallback: (targetState: ApplicationState) => Promise<boolean>): void;
27
21
  getState(): ApplicationState;
28
- get previous(): ApplicationState;
22
+ get previous(): ApplicationState | null;
29
23
  get context(): any;
30
24
  /**
31
25
  * set current page state
32
26
  * @param state can be either just a state or a state and context (which can be sub-state, or anything else)
33
27
  */
34
28
  set state(state: ApplicationStateName | [ApplicationStateName, ...any]);
35
- /** attempts to restore state from current url. Currently, works only in hash mode */
29
+ /** attempts to restore state from current url. */
36
30
  restoreState(defaultState: ApplicationStateName): void;
37
31
  /**
38
32
  *
@@ -50,7 +44,8 @@ export declare class StateManager {
50
44
  addState(name: string, pageName?: string, route?: RegExp | string, mode?: string | string[]): void;
51
45
  registerStateByState(state: ApplicationState): void;
52
46
  onBeforeChange(handler: (target: ApplicationState, context?: any) => boolean | Promise<boolean>): void;
53
- onAfterChange(handler: (state: ApplicationState, context?: any, previous?: ApplicationState) => void | Promise<void>): void;
47
+ onAfterChange(handler: (state: ApplicationState, context?: any, previous?: ApplicationState | null) => void | Promise<void>): void;
48
+ onExit(stateName: ApplicationStateName, handler: (context?: any) => void | Promise<void>): void;
54
49
  onNotFound(handler: (path: string) => void): void;
55
50
  }
56
51
  export declare function createStateManager(mode?: RoutingMode, autostart?: boolean, routerInstance?: RouterType): StateManager;
@@ -2,28 +2,30 @@ import { router } from "./router";
2
2
  import dispatcher from "@dharmax/pubsub";
3
3
  export class StateManager {
4
4
  mode;
5
- allStates = {};
6
- appState;
7
- previousState;
8
- stateContext;
5
+ #allStates = {};
6
+ #appState = null;
7
+ #previousState = null;
8
+ #stateContext;
9
+ #currentTransitionId = 0;
9
10
  static dispatcher = dispatcher;
10
- changeAuthorities = [];
11
- router;
12
- beforeChangeHandlers = [];
13
- afterChangeHandlers = [];
11
+ #changeAuthorities = [];
12
+ #router;
13
+ #beforeChangeHandlers = [];
14
+ #afterChangeHandlers = [];
14
15
  constructor(mode = 'hash', autostart = true, routerInstance = router) {
15
16
  this.mode = mode;
16
- this.router = routerInstance;
17
+ this.#router = routerInstance;
18
+ this.#router.setMode(mode);
17
19
  if (autostart)
18
- this.router.listen(mode);
20
+ this.#router.listen(mode);
19
21
  }
20
22
  start() {
21
- this.router.listen(this.mode);
23
+ this.#router.listen(this.mode);
22
24
  }
23
25
  stop() {
24
26
  // unlisten router to cleanup event listeners
25
27
  // @ts-ignore - older Router versions may not have unlisten
26
- this.router.unlisten && this.router.unlisten();
28
+ this.#router.unlisten && this.#router.unlisten();
27
29
  }
28
30
  onChange(handler) {
29
31
  return StateManager.dispatcher.on('state:changed', handler);
@@ -34,16 +36,16 @@ export class StateManager {
34
36
  requested doesn't happen.
35
37
  **/
36
38
  registerChangeAuthority(authorityCallback) {
37
- this.changeAuthorities.push(authorityCallback);
39
+ this.#changeAuthorities.push(authorityCallback);
38
40
  }
39
41
  getState() {
40
- return this.appState || {};
42
+ return this.#appState || {};
41
43
  }
42
44
  get previous() {
43
- return this.previousState;
45
+ return this.#previousState;
44
46
  }
45
47
  get context() {
46
- return this.stateContext;
48
+ return this.#stateContext;
47
49
  }
48
50
  /**
49
51
  * set current page state
@@ -52,16 +54,21 @@ export class StateManager {
52
54
  set state(state) {
53
55
  if (Array.isArray(state)) {
54
56
  const sName = state.shift();
55
- this.setState(sName, state);
57
+ this.setState(sName, state.length === 1 ? state[0] : state);
56
58
  }
57
59
  else
58
60
  this.setState(state);
59
61
  }
60
- /** attempts to restore state from current url. Currently, works only in hash mode */
62
+ /** attempts to restore state from current url. */
61
63
  restoreState(defaultState) {
62
- if (this.router.navigate(window.location.pathname))
64
+ if (this.#router.handleChange())
63
65
  return;
64
- this.router.navigate(defaultState);
66
+ const state = this.#allStates[defaultState];
67
+ let path = defaultState;
68
+ if (state && typeof state.route === 'string') {
69
+ path = state.route;
70
+ }
71
+ this.#router.navigate(path);
65
72
  }
66
73
  /**
67
74
  *
@@ -69,24 +76,33 @@ export class StateManager {
69
76
  * @param context extra context (e.g. sub-state)
70
77
  */
71
78
  async setState(stateName, context) {
72
- const newState = this.allStates[stateName];
79
+ const transitionId = ++this.#currentTransitionId;
80
+ const newState = this.#allStates[stateName];
73
81
  if (!newState) {
74
- alert(`Undefined app state ${stateName}`);
75
- return false;
82
+ throw new Error(`Undefined app state ${stateName}`);
76
83
  }
77
84
  // check if the state change was declined by any change authority and if so - don't do it and return false
78
- const changeConfirmations = await Promise.all(this.changeAuthorities.map(authority => authority(newState)));
79
- const vetoes = await Promise.all(this.beforeChangeHandlers.map(h => Promise.resolve(h(newState, context))));
85
+ const changeConfirmations = await Promise.all(this.#changeAuthorities.map(authority => authority(newState)));
86
+ if (transitionId !== this.#currentTransitionId)
87
+ return false;
88
+ const vetoes = await Promise.all(this.#beforeChangeHandlers.map(h => Promise.resolve(h(newState, context))));
89
+ if (transitionId !== this.#currentTransitionId)
90
+ return false;
80
91
  if (changeConfirmations.includes(false) || vetoes.includes(false))
81
92
  return false;
93
+ if (this.#appState && this.#appState.onExit) {
94
+ await this.#appState.onExit(this.#stateContext);
95
+ if (transitionId !== this.#currentTransitionId)
96
+ return false;
97
+ }
82
98
  // perform the change
83
- this.previousState = this.appState;
84
- this.stateContext = context;
85
- this.appState = newState;
86
- dispatcher.trigger('state-manager', 'state', 'changed', this.appState);
99
+ this.#previousState = this.#appState;
100
+ this.#stateContext = context;
101
+ this.#appState = newState;
102
+ dispatcher.trigger('state-manager', 'state', 'changed', this.#appState);
87
103
  // afterChange hooks
88
- for (const cb of this.afterChangeHandlers) {
89
- await cb(this.appState, context, this.previousState);
104
+ for (const cb of this.#afterChangeHandlers) {
105
+ await cb(this.#appState, context, this.#previousState);
90
106
  }
91
107
  return true;
92
108
  }
@@ -113,9 +129,9 @@ export class StateManager {
113
129
  });
114
130
  }
115
131
  registerStateByState(state) {
116
- this.allStates[state.name] = state;
132
+ this.#allStates[state.name] = state;
117
133
  const self = this;
118
- this.router.add(state.route, async function (...captures) {
134
+ this.#router.add(state.route, async function (...captures) {
119
135
  // prefer named params (from router string patterns), else multi-captures array, else first capture
120
136
  // @ts-ignore
121
137
  const named = (this && this.params) || null;
@@ -129,15 +145,21 @@ export class StateManager {
129
145
  });
130
146
  }
131
147
  onBeforeChange(handler) {
132
- this.beforeChangeHandlers.push(handler);
148
+ this.#beforeChangeHandlers.push(handler);
133
149
  }
134
150
  onAfterChange(handler) {
135
- this.afterChangeHandlers.push(handler);
151
+ this.#afterChangeHandlers.push(handler);
152
+ }
153
+ onExit(stateName, handler) {
154
+ const state = this.#allStates[stateName];
155
+ if (state) {
156
+ state.onExit = handler;
157
+ }
136
158
  }
137
159
  onNotFound(handler) {
138
160
  // proxy to router-level notFound
139
161
  // @ts-ignore - older Router versions may not have onNotFound
140
- this.router.onNotFound && this.router.onNotFound(handler);
162
+ this.#router.onNotFound && this.#router.onNotFound(handler);
141
163
  }
142
164
  }
143
165
  export function createStateManager(mode = 'hash', autostart = true, routerInstance = router) {
@@ -3,58 +3,61 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.router = exports.Router = void 0;
4
4
  exports.createRouter = createRouter;
5
5
  class Router {
6
- mode = 'hash';
7
- routes = [];
8
- root = '/';
9
- rootCompare = '';
10
- baseLocation = null;
6
+ #mode = 'hash';
7
+ #routes = [];
8
+ #root = '/';
9
+ #rootCompare = '';
10
+ #baseLocation = null;
11
11
  staticFilters = [];
12
- isListening = false;
13
- bound = {};
14
- notFoundHandler;
15
- decodeParams = false;
12
+ #isListening = false;
13
+ #bound = {};
14
+ #notFoundHandler;
15
+ #decodeParams = false;
16
16
  constructor() {
17
17
  this.staticFilters.push(url => {
18
18
  const staticFileExtensions = ['.json', '.css', '.js', '.png', '.jpg', '.svg', '.webp', '.md', '.ejs', '.jsm', '.txt'];
19
19
  return staticFileExtensions.some(ext => url.endsWith(ext));
20
20
  });
21
21
  }
22
- cleanPathString(path) {
22
+ #cleanPathString(path) {
23
23
  path = path.replace(/\/$/, '').replace(/^\//, '');
24
24
  return path = path.replace(/#{2,}/g, '#');
25
25
  }
26
- clearQuery(url) {
26
+ #clearQuery(url) {
27
27
  const [path, query] = url.split('?');
28
28
  if (!query)
29
29
  return path;
30
30
  const [_, hash] = query.split('#');
31
31
  return hash ? `${path}#${hash}` : path;
32
32
  }
33
- isStaticFile(url) {
34
- return (this.staticFilters || []).some(filter => filter(url));
33
+ #isStaticFile(url) {
34
+ return this.staticFilters.some(filter => filter(url));
35
35
  }
36
36
  resetRoot(root) {
37
- const cleaned = this.cleanPathString(root);
38
- this.root = '/' + cleaned + '/';
39
- this.rootCompare = cleaned ? cleaned + '/' : '';
37
+ const cleaned = this.#cleanPathString(root);
38
+ this.#root = '/' + cleaned + '/';
39
+ this.#rootCompare = cleaned ? cleaned + '/' : '';
40
+ }
41
+ setMode(mode) {
42
+ this.#mode = mode;
40
43
  }
41
44
  getLocation() {
42
- if (!this.isBrowser())
45
+ if (!this.#isBrowser())
43
46
  return '';
44
- if (this.mode === 'history') {
47
+ if (this.#mode === 'history') {
45
48
  let fragment = decodeURI(window.location.pathname + window.location.search);
46
- fragment = this.clearQuery(fragment);
49
+ fragment = this.#clearQuery(fragment);
47
50
  // strip leading slash for comparison convenience
48
51
  fragment = fragment.replace(/^\//, '');
49
- if (this.root !== '/' && this.rootCompare && fragment.startsWith(this.rootCompare)) {
50
- fragment = fragment.slice(this.rootCompare.length);
52
+ if (this.#root !== '/' && this.#rootCompare && fragment.startsWith(this.#rootCompare)) {
53
+ fragment = fragment.slice(this.#rootCompare.length);
51
54
  }
52
- fragment = this.cleanPathString(fragment);
55
+ fragment = this.#cleanPathString(fragment);
53
56
  return fragment;
54
57
  }
55
58
  else {
56
59
  const match = window.location.href.match(/#(.*)$/);
57
- return match ? this.clearQuery(match[1]) : '';
60
+ return match ? this.#clearQuery(match[1]) : '';
58
61
  }
59
62
  }
60
63
  add(pattern, handler) {
@@ -64,23 +67,23 @@ class Router {
64
67
  pattern = /^.*$/; // Match any path
65
68
  }
66
69
  else if (typeof pattern === 'string') {
67
- const compiled = this.compilePattern(pattern);
70
+ const compiled = this.#compilePattern(pattern);
68
71
  pattern = compiled.regex;
69
72
  paramNames = compiled.paramNames;
70
73
  }
71
- this.routes.push({ pattern: pattern, handler: handler, paramNames });
74
+ this.#routes.push({ pattern: pattern, handler: handler, paramNames });
72
75
  return this;
73
76
  }
74
77
  onNotFound(handler) {
75
- this.notFoundHandler = handler;
78
+ this.#notFoundHandler = handler;
76
79
  return this;
77
80
  }
78
81
  setDecodeParams(decode) {
79
- this.decodeParams = decode;
82
+ this.#decodeParams = decode;
80
83
  return this;
81
84
  }
82
85
  getQueryParams(search) {
83
- if (!this.isBrowser() && !search)
86
+ if (!this.#isBrowser() && !search)
84
87
  return {};
85
88
  const qs = typeof search === 'string' ? search : window.location.search || '';
86
89
  const usp = new URLSearchParams(qs);
@@ -88,10 +91,10 @@ class Router {
88
91
  usp.forEach((v, k) => { obj[k] = v; });
89
92
  return obj;
90
93
  }
91
- isBrowser() {
94
+ #isBrowser() {
92
95
  return typeof window !== 'undefined' && typeof document !== 'undefined';
93
96
  }
94
- compilePattern(pattern) {
97
+ #compilePattern(pattern) {
95
98
  // normalize leading slash to align with cleanPathString behavior
96
99
  const normalized = pattern.replace(/^\//, '');
97
100
  const paramNames = [];
@@ -111,14 +114,14 @@ class Router {
111
114
  */
112
115
  handleChange(location) {
113
116
  const path = (location ?? this.getLocation()) || '';
114
- if (this.isStaticFile(path))
117
+ if (this.#isStaticFile(path))
115
118
  return false; // Bypass routing for static files
116
- for (const route of this.routes) {
119
+ for (const route of this.#routes) {
117
120
  const match = route.pattern ? path.match(route.pattern) : null;
118
121
  if (match) {
119
122
  match.shift(); // Remove the full match element
120
123
  const queryParams = this.getQueryParams();
121
- const captures = this.decodeParams ? match.map(v => safeDecode(v)) : match;
124
+ const captures = this.#decodeParams ? match.map(v => safeDecode(v)) : match;
122
125
  let params;
123
126
  if (route.paramNames && route.paramNames.length) {
124
127
  params = {};
@@ -128,8 +131,8 @@ class Router {
128
131
  return true;
129
132
  }
130
133
  }
131
- if (this.notFoundHandler) {
132
- this.notFoundHandler(path);
134
+ if (this.#notFoundHandler) {
135
+ this.#notFoundHandler(path);
133
136
  return true;
134
137
  }
135
138
  if (path)
@@ -137,20 +140,20 @@ class Router {
137
140
  return false;
138
141
  }
139
142
  listen(mode = 'hash') {
140
- if (!this.isBrowser())
143
+ if (!this.#isBrowser())
141
144
  return;
142
145
  // avoid duplicate listeners
143
- if (this.isListening)
146
+ if (this.#isListening)
144
147
  this.unlisten();
145
148
  const self = this;
146
- this.mode = mode;
149
+ this.#mode = mode;
147
150
  const handler = (path) => {
148
151
  const p = path || location.href.split('#')[0];
149
- if (self.isStaticFile(p))
152
+ if (self.#isStaticFile(p))
150
153
  return;
151
154
  const currentLocation = self.getLocation();
152
- if (self.baseLocation !== currentLocation) {
153
- self.baseLocation = currentLocation;
155
+ if (self.#baseLocation !== currentLocation) {
156
+ self.#baseLocation = currentLocation;
154
157
  self.handleChange(currentLocation);
155
158
  }
156
159
  };
@@ -164,9 +167,9 @@ class Router {
164
167
  return;
165
168
  }
166
169
  const target = event.target;
167
- if (!target || !('closest' in target))
170
+ if (!target)
168
171
  return;
169
- const anchor = target.closest?.('a');
172
+ const anchor = target.closest('a');
170
173
  if (!anchor)
171
174
  return;
172
175
  if (event.type === 'keydown') {
@@ -191,7 +194,7 @@ class Router {
191
194
  if (rel && /\bnoreferrer\b/i.test(rel))
192
195
  return;
193
196
  const pathWithQuery = url.pathname + (url.search || '');
194
- if (self.isStaticFile(pathWithQuery) || self.isStaticFile(url.pathname))
197
+ if (self.#isStaticFile(pathWithQuery) || self.#isStaticFile(url.pathname))
195
198
  return;
196
199
  event.preventDefault();
197
200
  history.pushState({ path: pathWithQuery }, '', pathWithQuery);
@@ -199,53 +202,53 @@ class Router {
199
202
  };
200
203
  switch (mode) {
201
204
  case 'hash':
202
- this.bound.hashchange = () => handler();
203
- window.addEventListener('hashchange', this.bound.hashchange);
205
+ this.#bound.hashchange = () => handler();
206
+ window.addEventListener('hashchange', this.#bound.hashchange);
204
207
  break;
205
208
  case 'history':
206
- this.bound.popstate = (event) => handler(event.state?.path);
207
- this.bound.click = handleInternalNavigation;
208
- this.bound.keydown = (event) => handleInternalNavigation(event);
209
- window.addEventListener('popstate', this.bound.popstate);
210
- document.addEventListener('click', this.bound.click);
211
- document.addEventListener('keydown', this.bound.keydown);
209
+ this.#bound.popstate = (event) => handler(this.getLocation());
210
+ this.#bound.click = handleInternalNavigation;
211
+ this.#bound.keydown = (event) => handleInternalNavigation(event);
212
+ window.addEventListener('popstate', this.#bound.popstate);
213
+ document.addEventListener('click', this.#bound.click);
214
+ document.addEventListener('keydown', this.#bound.keydown);
212
215
  break;
213
216
  }
214
- this.isListening = true;
217
+ this.#isListening = true;
215
218
  handler();
216
219
  }
217
220
  unlisten() {
218
- if (!this.isBrowser() || !this.isListening)
221
+ if (!this.#isBrowser() || !this.#isListening)
219
222
  return;
220
- switch (this.mode) {
223
+ switch (this.#mode) {
221
224
  case 'hash':
222
- if (this.bound.hashchange)
223
- window.removeEventListener('hashchange', this.bound.hashchange);
225
+ if (this.#bound.hashchange)
226
+ window.removeEventListener('hashchange', this.#bound.hashchange);
224
227
  break;
225
228
  case 'history':
226
- if (this.bound.popstate)
227
- window.removeEventListener('popstate', this.bound.popstate);
228
- if (this.bound.click)
229
- document.removeEventListener('click', this.bound.click);
230
- if (this.bound.keydown)
231
- document.removeEventListener('keydown', this.bound.keydown);
229
+ if (this.#bound.popstate)
230
+ window.removeEventListener('popstate', this.#bound.popstate);
231
+ if (this.#bound.click)
232
+ document.removeEventListener('click', this.#bound.click);
233
+ if (this.#bound.keydown)
234
+ document.removeEventListener('keydown', this.#bound.keydown);
232
235
  break;
233
236
  }
234
- this.bound = {};
235
- this.isListening = false;
237
+ this.#bound = {};
238
+ this.#isListening = false;
236
239
  }
237
240
  navigate(path = '', opts) {
238
- if (!this.isBrowser())
241
+ if (!this.#isBrowser())
239
242
  return false;
240
- if (this.mode === 'history') {
241
- const url = this.root + this.cleanPathString(path);
243
+ if (this.#mode === 'history') {
244
+ const url = this.#root + this.#cleanPathString(path);
242
245
  if (opts?.replace)
243
246
  history.replaceState(null, '', url);
244
247
  else
245
248
  history.pushState(null, '', url);
246
249
  }
247
250
  else
248
- window.location.hash = this.cleanPathString(path);
251
+ window.location.hash = this.#cleanPathString(path);
249
252
  return this.handleChange();
250
253
  }
251
254
  replace(path = '') {
@@ -1,28 +1,16 @@
1
1
  type RouteHandler = (...args: string[]) => void;
2
2
  export type RoutingMode = 'history' | 'hash';
3
3
  export declare class Router {
4
- private mode;
5
- private routes;
6
- private root;
7
- private rootCompare;
8
- private baseLocation;
4
+ #private;
9
5
  staticFilters: ((url: string) => boolean)[];
10
- private isListening;
11
- private bound;
12
- private notFoundHandler?;
13
- private decodeParams;
14
6
  constructor();
15
- private cleanPathString;
16
- private clearQuery;
17
- private isStaticFile;
18
7
  resetRoot(root: string): void;
8
+ setMode(mode: RoutingMode): void;
19
9
  getLocation(): string;
20
10
  add(pattern: RegExp | string | RouteHandler, handler?: RouteHandler): Router;
21
11
  onNotFound(handler: (path: string) => void): Router;
22
12
  setDecodeParams(decode: boolean): Router;
23
13
  getQueryParams(search?: string): Record<string, string>;
24
- private isBrowser;
25
- private compilePattern;
26
14
  /**
27
15
  *
28
16
  * @param location
@@ -6,28 +6,30 @@ const router_1 = require("./router");
6
6
  const pubsub_1 = require("@dharmax/pubsub");
7
7
  class StateManager {
8
8
  mode;
9
- allStates = {};
10
- appState;
11
- previousState;
12
- stateContext;
9
+ #allStates = {};
10
+ #appState = null;
11
+ #previousState = null;
12
+ #stateContext;
13
+ #currentTransitionId = 0;
13
14
  static dispatcher = pubsub_1.default;
14
- changeAuthorities = [];
15
- router;
16
- beforeChangeHandlers = [];
17
- afterChangeHandlers = [];
15
+ #changeAuthorities = [];
16
+ #router;
17
+ #beforeChangeHandlers = [];
18
+ #afterChangeHandlers = [];
18
19
  constructor(mode = 'hash', autostart = true, routerInstance = router_1.router) {
19
20
  this.mode = mode;
20
- this.router = routerInstance;
21
+ this.#router = routerInstance;
22
+ this.#router.setMode(mode);
21
23
  if (autostart)
22
- this.router.listen(mode);
24
+ this.#router.listen(mode);
23
25
  }
24
26
  start() {
25
- this.router.listen(this.mode);
27
+ this.#router.listen(this.mode);
26
28
  }
27
29
  stop() {
28
30
  // unlisten router to cleanup event listeners
29
31
  // @ts-ignore - older Router versions may not have unlisten
30
- this.router.unlisten && this.router.unlisten();
32
+ this.#router.unlisten && this.#router.unlisten();
31
33
  }
32
34
  onChange(handler) {
33
35
  return StateManager.dispatcher.on('state:changed', handler);
@@ -38,16 +40,16 @@ class StateManager {
38
40
  requested doesn't happen.
39
41
  **/
40
42
  registerChangeAuthority(authorityCallback) {
41
- this.changeAuthorities.push(authorityCallback);
43
+ this.#changeAuthorities.push(authorityCallback);
42
44
  }
43
45
  getState() {
44
- return this.appState || {};
46
+ return this.#appState || {};
45
47
  }
46
48
  get previous() {
47
- return this.previousState;
49
+ return this.#previousState;
48
50
  }
49
51
  get context() {
50
- return this.stateContext;
52
+ return this.#stateContext;
51
53
  }
52
54
  /**
53
55
  * set current page state
@@ -56,16 +58,21 @@ class StateManager {
56
58
  set state(state) {
57
59
  if (Array.isArray(state)) {
58
60
  const sName = state.shift();
59
- this.setState(sName, state);
61
+ this.setState(sName, state.length === 1 ? state[0] : state);
60
62
  }
61
63
  else
62
64
  this.setState(state);
63
65
  }
64
- /** attempts to restore state from current url. Currently, works only in hash mode */
66
+ /** attempts to restore state from current url. */
65
67
  restoreState(defaultState) {
66
- if (this.router.navigate(window.location.pathname))
68
+ if (this.#router.handleChange())
67
69
  return;
68
- this.router.navigate(defaultState);
70
+ const state = this.#allStates[defaultState];
71
+ let path = defaultState;
72
+ if (state && typeof state.route === 'string') {
73
+ path = state.route;
74
+ }
75
+ this.#router.navigate(path);
69
76
  }
70
77
  /**
71
78
  *
@@ -73,24 +80,33 @@ class StateManager {
73
80
  * @param context extra context (e.g. sub-state)
74
81
  */
75
82
  async setState(stateName, context) {
76
- const newState = this.allStates[stateName];
83
+ const transitionId = ++this.#currentTransitionId;
84
+ const newState = this.#allStates[stateName];
77
85
  if (!newState) {
78
- alert(`Undefined app state ${stateName}`);
79
- return false;
86
+ throw new Error(`Undefined app state ${stateName}`);
80
87
  }
81
88
  // check if the state change was declined by any change authority and if so - don't do it and return false
82
- const changeConfirmations = await Promise.all(this.changeAuthorities.map(authority => authority(newState)));
83
- const vetoes = await Promise.all(this.beforeChangeHandlers.map(h => Promise.resolve(h(newState, context))));
89
+ const changeConfirmations = await Promise.all(this.#changeAuthorities.map(authority => authority(newState)));
90
+ if (transitionId !== this.#currentTransitionId)
91
+ return false;
92
+ const vetoes = await Promise.all(this.#beforeChangeHandlers.map(h => Promise.resolve(h(newState, context))));
93
+ if (transitionId !== this.#currentTransitionId)
94
+ return false;
84
95
  if (changeConfirmations.includes(false) || vetoes.includes(false))
85
96
  return false;
97
+ if (this.#appState && this.#appState.onExit) {
98
+ await this.#appState.onExit(this.#stateContext);
99
+ if (transitionId !== this.#currentTransitionId)
100
+ return false;
101
+ }
86
102
  // perform the change
87
- this.previousState = this.appState;
88
- this.stateContext = context;
89
- this.appState = newState;
90
- pubsub_1.default.trigger('state-manager', 'state', 'changed', this.appState);
103
+ this.#previousState = this.#appState;
104
+ this.#stateContext = context;
105
+ this.#appState = newState;
106
+ pubsub_1.default.trigger('state-manager', 'state', 'changed', this.#appState);
91
107
  // afterChange hooks
92
- for (const cb of this.afterChangeHandlers) {
93
- await cb(this.appState, context, this.previousState);
108
+ for (const cb of this.#afterChangeHandlers) {
109
+ await cb(this.#appState, context, this.#previousState);
94
110
  }
95
111
  return true;
96
112
  }
@@ -117,9 +133,9 @@ class StateManager {
117
133
  });
118
134
  }
119
135
  registerStateByState(state) {
120
- this.allStates[state.name] = state;
136
+ this.#allStates[state.name] = state;
121
137
  const self = this;
122
- this.router.add(state.route, async function (...captures) {
138
+ this.#router.add(state.route, async function (...captures) {
123
139
  // prefer named params (from router string patterns), else multi-captures array, else first capture
124
140
  // @ts-ignore
125
141
  const named = (this && this.params) || null;
@@ -133,15 +149,21 @@ class StateManager {
133
149
  });
134
150
  }
135
151
  onBeforeChange(handler) {
136
- this.beforeChangeHandlers.push(handler);
152
+ this.#beforeChangeHandlers.push(handler);
137
153
  }
138
154
  onAfterChange(handler) {
139
- this.afterChangeHandlers.push(handler);
155
+ this.#afterChangeHandlers.push(handler);
156
+ }
157
+ onExit(stateName, handler) {
158
+ const state = this.#allStates[stateName];
159
+ if (state) {
160
+ state.onExit = handler;
161
+ }
140
162
  }
141
163
  onNotFound(handler) {
142
164
  // proxy to router-level notFound
143
165
  // @ts-ignore - older Router versions may not have onNotFound
144
- this.router.onNotFound && this.router.onNotFound(handler);
166
+ this.#router.onNotFound && this.#router.onNotFound(handler);
145
167
  }
146
168
  }
147
169
  exports.StateManager = StateManager;
@@ -6,33 +6,27 @@ export type ApplicationState = {
6
6
  pageName: string;
7
7
  route: RegExp | string;
8
8
  mode?: string | string[];
9
+ onExit?: (context?: any) => void | Promise<void>;
9
10
  };
10
11
  export type ChangeAuthority = (state: ApplicationState) => Promise<boolean>;
11
12
  export declare class StateManager {
13
+ #private;
12
14
  private mode;
13
- private allStates;
14
- private appState;
15
- private previousState;
16
- private stateContext;
17
15
  static dispatcher: import("@dharmax/pubsub").PubSub;
18
- private changeAuthorities;
19
- private router;
20
- private beforeChangeHandlers;
21
- private afterChangeHandlers;
22
16
  constructor(mode?: RoutingMode, autostart?: boolean, routerInstance?: RouterType);
23
17
  start(): void;
24
18
  stop(): void;
25
19
  onChange(handler: (event: PubSubEvent, data: any) => void): IPubSubHandle;
26
20
  registerChangeAuthority(authorityCallback: (targetState: ApplicationState) => Promise<boolean>): void;
27
21
  getState(): ApplicationState;
28
- get previous(): ApplicationState;
22
+ get previous(): ApplicationState | null;
29
23
  get context(): any;
30
24
  /**
31
25
  * set current page state
32
26
  * @param state can be either just a state or a state and context (which can be sub-state, or anything else)
33
27
  */
34
28
  set state(state: ApplicationStateName | [ApplicationStateName, ...any]);
35
- /** attempts to restore state from current url. Currently, works only in hash mode */
29
+ /** attempts to restore state from current url. */
36
30
  restoreState(defaultState: ApplicationStateName): void;
37
31
  /**
38
32
  *
@@ -50,7 +44,8 @@ export declare class StateManager {
50
44
  addState(name: string, pageName?: string, route?: RegExp | string, mode?: string | string[]): void;
51
45
  registerStateByState(state: ApplicationState): void;
52
46
  onBeforeChange(handler: (target: ApplicationState, context?: any) => boolean | Promise<boolean>): void;
53
- onAfterChange(handler: (state: ApplicationState, context?: any, previous?: ApplicationState) => void | Promise<void>): void;
47
+ onAfterChange(handler: (state: ApplicationState, context?: any, previous?: ApplicationState | null) => void | Promise<void>): void;
48
+ onExit(stateName: ApplicationStateName, handler: (context?: any) => void | Promise<void>): void;
54
49
  onNotFound(handler: (path: string) => void): void;
55
50
  }
56
51
  export declare function createStateManager(mode?: RoutingMode, autostart?: boolean, routerInstance?: RouterType): StateManager;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dharmax/state-router",
3
- "version": "3.2.1",
3
+ "version": "4.0.1",
4
4
  "description": "A cute and tight router and application state controller",
5
5
  "type": "module",
6
6
  "main": "dist-cjs/index.cjs",
@@ -16,7 +16,7 @@
16
16
  },
17
17
 
18
18
  "scripts": {
19
- "test": "vitest run --coverage",
19
+ "test": "vitest run --coverage && sed -i \"s/<a name='/<a id='/g\" coverage/*.html",
20
20
  "test:watch": "vitest",
21
21
  "build": "npm run build:esm && npm run build:cjs",
22
22
  "build:esm": "npx tsc",