@dharmax/state-router 3.2.1 → 4.0.2

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,17 @@
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;
13
+ willHandle(path: string): boolean;
23
14
  getQueryParams(search?: string): Record<string, string>;
24
- private isBrowser;
25
- private compilePattern;
26
15
  /**
27
16
  *
28
17
  * @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,39 @@ 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
  }
81
+ willHandle(path) {
82
+ path = decodeURI(path);
83
+ path = this.#clearQuery(path);
84
+ path = path.replace(/^\//, '');
85
+ if (this.#root !== '/' && this.#rootCompare && path.startsWith(this.#rootCompare)) {
86
+ path = path.slice(this.#rootCompare.length);
87
+ }
88
+ path = this.#cleanPathString(path);
89
+ if (this.#isStaticFile(path))
90
+ return false;
91
+ for (const route of this.#routes) {
92
+ if (route.pattern && route.pattern.test(path))
93
+ return true;
94
+ }
95
+ return !!this.#notFoundHandler;
96
+ }
78
97
  getQueryParams(search) {
79
- if (!this.isBrowser() && !search)
98
+ if (!this.#isBrowser() && !search)
80
99
  return {};
81
100
  const qs = typeof search === 'string' ? search : window.location.search || '';
82
101
  const usp = new URLSearchParams(qs);
@@ -84,10 +103,10 @@ export class Router {
84
103
  usp.forEach((v, k) => { obj[k] = v; });
85
104
  return obj;
86
105
  }
87
- isBrowser() {
106
+ #isBrowser() {
88
107
  return typeof window !== 'undefined' && typeof document !== 'undefined';
89
108
  }
90
- compilePattern(pattern) {
109
+ #compilePattern(pattern) {
91
110
  // normalize leading slash to align with cleanPathString behavior
92
111
  const normalized = pattern.replace(/^\//, '');
93
112
  const paramNames = [];
@@ -107,14 +126,14 @@ export class Router {
107
126
  */
108
127
  handleChange(location) {
109
128
  const path = (location ?? this.getLocation()) || '';
110
- if (this.isStaticFile(path))
129
+ if (this.#isStaticFile(path))
111
130
  return false; // Bypass routing for static files
112
- for (const route of this.routes) {
131
+ for (const route of this.#routes) {
113
132
  const match = route.pattern ? path.match(route.pattern) : null;
114
133
  if (match) {
115
134
  match.shift(); // Remove the full match element
116
135
  const queryParams = this.getQueryParams();
117
- const captures = this.decodeParams ? match.map(v => safeDecode(v)) : match;
136
+ const captures = this.#decodeParams ? match.map(v => safeDecode(v)) : match;
118
137
  let params;
119
138
  if (route.paramNames && route.paramNames.length) {
120
139
  params = {};
@@ -124,8 +143,8 @@ export class Router {
124
143
  return true;
125
144
  }
126
145
  }
127
- if (this.notFoundHandler) {
128
- this.notFoundHandler(path);
146
+ if (this.#notFoundHandler) {
147
+ this.#notFoundHandler(path);
129
148
  return true;
130
149
  }
131
150
  if (path)
@@ -133,20 +152,20 @@ export class Router {
133
152
  return false;
134
153
  }
135
154
  listen(mode = 'hash') {
136
- if (!this.isBrowser())
155
+ if (!this.#isBrowser())
137
156
  return;
138
157
  // avoid duplicate listeners
139
- if (this.isListening)
158
+ if (this.#isListening)
140
159
  this.unlisten();
141
160
  const self = this;
142
- this.mode = mode;
161
+ this.#mode = mode;
143
162
  const handler = (path) => {
144
163
  const p = path || location.href.split('#')[0];
145
- if (self.isStaticFile(p))
164
+ if (self.#isStaticFile(p))
146
165
  return;
147
166
  const currentLocation = self.getLocation();
148
- if (self.baseLocation !== currentLocation) {
149
- self.baseLocation = currentLocation;
167
+ if (self.#baseLocation !== currentLocation) {
168
+ self.#baseLocation = currentLocation;
150
169
  self.handleChange(currentLocation);
151
170
  }
152
171
  };
@@ -160,9 +179,9 @@ export class Router {
160
179
  return;
161
180
  }
162
181
  const target = event.target;
163
- if (!target || !('closest' in target))
182
+ if (!target)
164
183
  return;
165
- const anchor = target.closest?.('a');
184
+ const anchor = target.closest('a');
166
185
  if (!anchor)
167
186
  return;
168
187
  if (event.type === 'keydown') {
@@ -187,7 +206,10 @@ export class Router {
187
206
  if (rel && /\bnoreferrer\b/i.test(rel))
188
207
  return;
189
208
  const pathWithQuery = url.pathname + (url.search || '');
190
- if (self.isStaticFile(pathWithQuery) || self.isStaticFile(url.pathname))
209
+ if (self.#isStaticFile(pathWithQuery) || self.#isStaticFile(url.pathname))
210
+ return;
211
+ // Only intercept if we have a handler for this route
212
+ if (!self.willHandle(pathWithQuery))
191
213
  return;
192
214
  event.preventDefault();
193
215
  history.pushState({ path: pathWithQuery }, '', pathWithQuery);
@@ -195,53 +217,53 @@ export class Router {
195
217
  };
196
218
  switch (mode) {
197
219
  case 'hash':
198
- this.bound.hashchange = () => handler();
199
- window.addEventListener('hashchange', this.bound.hashchange);
220
+ this.#bound.hashchange = () => handler();
221
+ window.addEventListener('hashchange', this.#bound.hashchange);
200
222
  break;
201
223
  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);
224
+ this.#bound.popstate = (event) => handler(this.getLocation());
225
+ this.#bound.click = handleInternalNavigation;
226
+ this.#bound.keydown = (event) => handleInternalNavigation(event);
227
+ window.addEventListener('popstate', this.#bound.popstate);
228
+ document.addEventListener('click', this.#bound.click);
229
+ document.addEventListener('keydown', this.#bound.keydown);
208
230
  break;
209
231
  }
210
- this.isListening = true;
232
+ this.#isListening = true;
211
233
  handler();
212
234
  }
213
235
  unlisten() {
214
- if (!this.isBrowser() || !this.isListening)
236
+ if (!this.#isBrowser() || !this.#isListening)
215
237
  return;
216
- switch (this.mode) {
238
+ switch (this.#mode) {
217
239
  case 'hash':
218
- if (this.bound.hashchange)
219
- window.removeEventListener('hashchange', this.bound.hashchange);
240
+ if (this.#bound.hashchange)
241
+ window.removeEventListener('hashchange', this.#bound.hashchange);
220
242
  break;
221
243
  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);
244
+ if (this.#bound.popstate)
245
+ window.removeEventListener('popstate', this.#bound.popstate);
246
+ if (this.#bound.click)
247
+ document.removeEventListener('click', this.#bound.click);
248
+ if (this.#bound.keydown)
249
+ document.removeEventListener('keydown', this.#bound.keydown);
228
250
  break;
229
251
  }
230
- this.bound = {};
231
- this.isListening = false;
252
+ this.#bound = {};
253
+ this.#isListening = false;
232
254
  }
233
255
  navigate(path = '', opts) {
234
- if (!this.isBrowser())
256
+ if (!this.#isBrowser())
235
257
  return false;
236
- if (this.mode === 'history') {
237
- const url = this.root + this.cleanPathString(path);
258
+ if (this.#mode === 'history') {
259
+ const url = this.#root + this.#cleanPathString(path);
238
260
  if (opts?.replace)
239
261
  history.replaceState(null, '', url);
240
262
  else
241
263
  history.pushState(null, '', url);
242
264
  }
243
265
  else
244
- window.location.hash = this.cleanPathString(path);
266
+ window.location.hash = this.#cleanPathString(path);
245
267
  return this.handleChange();
246
268
  }
247
269
  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,39 @@ 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
  }
85
+ willHandle(path) {
86
+ path = decodeURI(path);
87
+ path = this.#clearQuery(path);
88
+ path = path.replace(/^\//, '');
89
+ if (this.#root !== '/' && this.#rootCompare && path.startsWith(this.#rootCompare)) {
90
+ path = path.slice(this.#rootCompare.length);
91
+ }
92
+ path = this.#cleanPathString(path);
93
+ if (this.#isStaticFile(path))
94
+ return false;
95
+ for (const route of this.#routes) {
96
+ if (route.pattern && route.pattern.test(path))
97
+ return true;
98
+ }
99
+ return !!this.#notFoundHandler;
100
+ }
82
101
  getQueryParams(search) {
83
- if (!this.isBrowser() && !search)
102
+ if (!this.#isBrowser() && !search)
84
103
  return {};
85
104
  const qs = typeof search === 'string' ? search : window.location.search || '';
86
105
  const usp = new URLSearchParams(qs);
@@ -88,10 +107,10 @@ class Router {
88
107
  usp.forEach((v, k) => { obj[k] = v; });
89
108
  return obj;
90
109
  }
91
- isBrowser() {
110
+ #isBrowser() {
92
111
  return typeof window !== 'undefined' && typeof document !== 'undefined';
93
112
  }
94
- compilePattern(pattern) {
113
+ #compilePattern(pattern) {
95
114
  // normalize leading slash to align with cleanPathString behavior
96
115
  const normalized = pattern.replace(/^\//, '');
97
116
  const paramNames = [];
@@ -111,14 +130,14 @@ class Router {
111
130
  */
112
131
  handleChange(location) {
113
132
  const path = (location ?? this.getLocation()) || '';
114
- if (this.isStaticFile(path))
133
+ if (this.#isStaticFile(path))
115
134
  return false; // Bypass routing for static files
116
- for (const route of this.routes) {
135
+ for (const route of this.#routes) {
117
136
  const match = route.pattern ? path.match(route.pattern) : null;
118
137
  if (match) {
119
138
  match.shift(); // Remove the full match element
120
139
  const queryParams = this.getQueryParams();
121
- const captures = this.decodeParams ? match.map(v => safeDecode(v)) : match;
140
+ const captures = this.#decodeParams ? match.map(v => safeDecode(v)) : match;
122
141
  let params;
123
142
  if (route.paramNames && route.paramNames.length) {
124
143
  params = {};
@@ -128,8 +147,8 @@ class Router {
128
147
  return true;
129
148
  }
130
149
  }
131
- if (this.notFoundHandler) {
132
- this.notFoundHandler(path);
150
+ if (this.#notFoundHandler) {
151
+ this.#notFoundHandler(path);
133
152
  return true;
134
153
  }
135
154
  if (path)
@@ -137,20 +156,20 @@ class Router {
137
156
  return false;
138
157
  }
139
158
  listen(mode = 'hash') {
140
- if (!this.isBrowser())
159
+ if (!this.#isBrowser())
141
160
  return;
142
161
  // avoid duplicate listeners
143
- if (this.isListening)
162
+ if (this.#isListening)
144
163
  this.unlisten();
145
164
  const self = this;
146
- this.mode = mode;
165
+ this.#mode = mode;
147
166
  const handler = (path) => {
148
167
  const p = path || location.href.split('#')[0];
149
- if (self.isStaticFile(p))
168
+ if (self.#isStaticFile(p))
150
169
  return;
151
170
  const currentLocation = self.getLocation();
152
- if (self.baseLocation !== currentLocation) {
153
- self.baseLocation = currentLocation;
171
+ if (self.#baseLocation !== currentLocation) {
172
+ self.#baseLocation = currentLocation;
154
173
  self.handleChange(currentLocation);
155
174
  }
156
175
  };
@@ -164,9 +183,9 @@ class Router {
164
183
  return;
165
184
  }
166
185
  const target = event.target;
167
- if (!target || !('closest' in target))
186
+ if (!target)
168
187
  return;
169
- const anchor = target.closest?.('a');
188
+ const anchor = target.closest('a');
170
189
  if (!anchor)
171
190
  return;
172
191
  if (event.type === 'keydown') {
@@ -191,7 +210,10 @@ class Router {
191
210
  if (rel && /\bnoreferrer\b/i.test(rel))
192
211
  return;
193
212
  const pathWithQuery = url.pathname + (url.search || '');
194
- if (self.isStaticFile(pathWithQuery) || self.isStaticFile(url.pathname))
213
+ if (self.#isStaticFile(pathWithQuery) || self.#isStaticFile(url.pathname))
214
+ return;
215
+ // Only intercept if we have a handler for this route
216
+ if (!self.willHandle(pathWithQuery))
195
217
  return;
196
218
  event.preventDefault();
197
219
  history.pushState({ path: pathWithQuery }, '', pathWithQuery);
@@ -199,53 +221,53 @@ class Router {
199
221
  };
200
222
  switch (mode) {
201
223
  case 'hash':
202
- this.bound.hashchange = () => handler();
203
- window.addEventListener('hashchange', this.bound.hashchange);
224
+ this.#bound.hashchange = () => handler();
225
+ window.addEventListener('hashchange', this.#bound.hashchange);
204
226
  break;
205
227
  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);
228
+ this.#bound.popstate = (event) => handler(this.getLocation());
229
+ this.#bound.click = handleInternalNavigation;
230
+ this.#bound.keydown = (event) => handleInternalNavigation(event);
231
+ window.addEventListener('popstate', this.#bound.popstate);
232
+ document.addEventListener('click', this.#bound.click);
233
+ document.addEventListener('keydown', this.#bound.keydown);
212
234
  break;
213
235
  }
214
- this.isListening = true;
236
+ this.#isListening = true;
215
237
  handler();
216
238
  }
217
239
  unlisten() {
218
- if (!this.isBrowser() || !this.isListening)
240
+ if (!this.#isBrowser() || !this.#isListening)
219
241
  return;
220
- switch (this.mode) {
242
+ switch (this.#mode) {
221
243
  case 'hash':
222
- if (this.bound.hashchange)
223
- window.removeEventListener('hashchange', this.bound.hashchange);
244
+ if (this.#bound.hashchange)
245
+ window.removeEventListener('hashchange', this.#bound.hashchange);
224
246
  break;
225
247
  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);
248
+ if (this.#bound.popstate)
249
+ window.removeEventListener('popstate', this.#bound.popstate);
250
+ if (this.#bound.click)
251
+ document.removeEventListener('click', this.#bound.click);
252
+ if (this.#bound.keydown)
253
+ document.removeEventListener('keydown', this.#bound.keydown);
232
254
  break;
233
255
  }
234
- this.bound = {};
235
- this.isListening = false;
256
+ this.#bound = {};
257
+ this.#isListening = false;
236
258
  }
237
259
  navigate(path = '', opts) {
238
- if (!this.isBrowser())
260
+ if (!this.#isBrowser())
239
261
  return false;
240
- if (this.mode === 'history') {
241
- const url = this.root + this.cleanPathString(path);
262
+ if (this.#mode === 'history') {
263
+ const url = this.#root + this.#cleanPathString(path);
242
264
  if (opts?.replace)
243
265
  history.replaceState(null, '', url);
244
266
  else
245
267
  history.pushState(null, '', url);
246
268
  }
247
269
  else
248
- window.location.hash = this.cleanPathString(path);
270
+ window.location.hash = this.#cleanPathString(path);
249
271
  return this.handleChange();
250
272
  }
251
273
  replace(path = '') {
@@ -1,28 +1,17 @@
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;
13
+ willHandle(path: string): boolean;
23
14
  getQueryParams(search?: string): Record<string, string>;
24
- private isBrowser;
25
- private compilePattern;
26
15
  /**
27
16
  *
28
17
  * @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.2",
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",