forked from majo44/storeon-async-router
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.ts
359 lines (325 loc) · 10.7 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
import { StoreonStore } from 'storeon';
/**
* Navigation represents ongoing navigation.
*/
export interface Navigation {
/**
* Unique identifier of navigation
*/
id: number;
/**
* Requested url.
*/
url: string;
/**
* Force the navigation, for the cases when even for same url as current have to be handled.
*/
force?: boolean;
/**
* Additional options for navigation, for browser url navigation it can be
* eg. replace - for replacing url in the url bar, ect..
*/
options?: any;
}
export interface NavigationState extends Navigation {
/**
* Url params. For the case when provided route regexp
* contains some parameters groups.
*/
params?: {[key: string]: string};
/**
* Route expression which matched that navigation.
*/
route: string;
}
export interface StateWithRouting {
/**
* Routing state.
*/
readonly routing: {
/**
* Registered handlers ids.
*/
readonly handles: Array<{id: number; route: string}>;
/**
* Current state of navigation.
*/
readonly current?: NavigationState;
/**
* The navigation which is in progress.
*/
readonly next?: Navigation;
/**
* The navigation which is in progress.
*/
readonly candidate?: Navigation;
};
}
/**
* Callback for route navigation
*/
export type RouteCallback =
(navigation: Navigation, signal: AbortSignal) => (void | Promise<void>);
/**
* Registered routes cache.
*/
const routes: {[key: number]: {id: number; route: string; regexp: RegExp; callback: RouteCallback}} = {};
/**
* Next handle id.
* @type {number}
*/
let handleId = 0;
/**
* Next navigation id.
* @type {number}
*/
let navId = 0;
/**
* Event dispatched when handler is registered to route.
*/
export const REGISTER_EVENT = Symbol('REGISTER_ROUTE');
/**
* Event dispatched when handler is unregistered.
*/
export const UNREGISTER_EVENT = Symbol('UNREGISTER_ROUTE');
/**
* Event dispatched before navigation.
*/
export const PRE_NAVIGATE_EVENT = Symbol('PRE_NAVIGATE_EVENT');
/**
* Event dispatched to start navigation.
*/
export const NAVIGATE_EVENT = Symbol('NAVIGATE');
/**
* Event dispatched after end of navigation.
*/
export const POST_NAVIGATE_EVENT = Symbol('POST_NAVIGATE_EVENT');
/**
* Event dispatched when navigation is ended successfully.
*/
export const NAVIGATION_ENDED_EVENT = Symbol('NAVIGATION_ENDED');
/**
* Event dispatched when navigation is failed.
*/
export const NAVIGATION_FAILED_EVENT = Symbol('NAVIGATION_FAILED');
/**
* Event dispatched when navigation is cancelled.
*/
export const NAVIGATION_CANCELLED_EVENT = Symbol('NAVIGATE_CANCELLED');
/**
* Event dispatched when navigation is ignored.
*/
export const NAVIGATION_IGNORED_EVENT = Symbol('NAVIGATE_IGNORED');
/**
* Event dispatched when navigation have to be cancelled.
*/
export const CANCEL_EVENT = Symbol('CANCEL_EVENT');
export interface NavigationEvent {
navigation: Navigation;
}
export interface RoutingEvents {
[REGISTER_EVENT]: { id: number; route: string };
[UNREGISTER_EVENT]: { id: number; route: string };
[PRE_NAVIGATE_EVENT]: NavigationEvent;
[NAVIGATE_EVENT]: NavigationEvent;
[NAVIGATION_ENDED_EVENT]: {navigation: NavigationState};
[NAVIGATION_FAILED_EVENT]: {navigation: Navigation; error: any };
[NAVIGATION_CANCELLED_EVENT]: undefined;
[NAVIGATION_IGNORED_EVENT]: NavigationEvent;
[POST_NAVIGATE_EVENT]: {navigation: Navigation; error?: any };
[CANCEL_EVENT]: undefined;
}
const ignoreNavigation = (navigation: Navigation, {current, next}: StateWithRouting['routing']) =>
// if it is not forced and
// if is for same url and not forced or
// if the navigation is to same url as current
!navigation.force && (next?.url === navigation.url || current?.url === navigation.url);
/**
* Storeon router module. Use it during your store creation.
*
* @example
* import createStore from 'storeon';
* import { asyncRoutingModule } from 'storeon-async-router;
* const store = createStore([asyncRoutingModule, your_module1 ...]);
*/
export const routingModule = (store: StoreonStore<StateWithRouting, RoutingEvents>) => {
const dispatch = store.dispatch.bind(store);
const on = store.on.bind(store);
/**
* Set default state on initialization.
*/
on('@init', () => ({ routing: { handles: [] } }));
// if the navigation have not to be ignored, set is as candidate
on(PRE_NAVIGATE_EVENT, ({ routing }, { navigation }) => {
if (ignoreNavigation(navigation, routing)) {
// we will ignore this navigation request
dispatch(NAVIGATION_IGNORED_EVENT, {navigation});
return;
}
setTimeout(() => {
if (store.get().routing.candidate?.id === navigation.id) {
dispatch(NAVIGATE_EVENT, { navigation })
} else {
dispatch(NAVIGATION_IGNORED_EVENT, {navigation});
}
});
return {
routing: {
...routing,
candidate: navigation,
},
};
});
// if we have something ongoing
// we have to cancel them
on(NAVIGATE_EVENT, ({ routing }) => {
if (routing.next) {
dispatch(NAVIGATION_CANCELLED_EVENT)
}
});
// set new ongoing next navigation
on(NAVIGATE_EVENT, ({ routing }) => ({
routing: {
...routing,
next: routing.candidate,
candidate: null
}
}));
// proceed ongoing navigation
on(
NAVIGATE_EVENT, async ({routing}, {navigation: n}) => {
let match: RegExpMatchArray = undefined;
let route = '';
// looking for handle which match navigation
const handle = routing.handles.find(({ id }) => {
match = n.url.match(routes[id].regexp);
({ route } = routes[id]);
return !!match;
});
// if there is no matched route, that is something wrong
if (!handle || !match) {
const error = new Error(`No route handle for url: ${n.url}`);
dispatch(NAVIGATION_FAILED_EVENT,{ navigation: n, error });
return;
}
// prepare navigation state
const navigation: NavigationState = {
...n,
route,
params: {
...(match.groups),
...(match).splice(1).reduce(
(r, g, i) => ({...r, [i.toString(10)]: g}), {}),
},
};
// taking callback for matched route
const { callback } = routes[handle.id];
// allows to cancellation
const ac = new AbortController();
const disconnect = on(NAVIGATION_CANCELLED_EVENT, () => ac.abort());
try {
// call callback
const res = callback(navigation, ac.signal);
// waits for the result
await res;
if (!ac.signal.aborted) {
// if was not cancelled, confirm end of navigation
dispatch(NAVIGATION_ENDED_EVENT, {navigation});
}
dispatch(POST_NAVIGATE_EVENT, {navigation});
} catch (error) {
if (error.name !== 'AbortError') {
// on any error
dispatch(NAVIGATION_FAILED_EVENT, {navigation, error});
}
} finally {
// at the end disconnect cancellation
disconnect();
}
},
);
// state updates
on(NAVIGATION_CANCELLED_EVENT, ({ routing }) => ({routing : { ...routing, candidate: undefined, next: undefined }}));
on(NAVIGATION_FAILED_EVENT, ({ routing }) => ({routing : { ...routing, candidate: undefined, next: undefined }}));
on(NAVIGATION_ENDED_EVENT, ({ routing }, {navigation}) =>
({routing : { ...routing, candidate: undefined, next: undefined, current: navigation }}));
// binding events to close promise
on(NAVIGATION_IGNORED_EVENT, (s, e) => dispatch(POST_NAVIGATE_EVENT, e));
on(NAVIGATION_FAILED_EVENT, (s, e) => dispatch(POST_NAVIGATE_EVENT, e));
// registration
on(REGISTER_EVENT, ({routing}, h) =>
({ routing: { ...routing, handles: [h, ...routing.handles] }}));
on(UNREGISTER_EVENT, ({routing}, {id}) =>
({ routing: { ...routing, handles: routing.handles.filter(i => i.id !== id) }}));
// public
on(CANCEL_EVENT, ({routing}) => {
/* istanbul ignore else */
if (routing.next || routing.candidate) {
dispatch(NAVIGATION_CANCELLED_EVENT)
}
});
};
/**
* Register the route handler to top of stack of handles.
*
* @example simple
* onNavigate(store, '/home', () => console.log('going home'));
*
* @example redirection
* onNavigate(store, '', () => navigate(store, '/404'));
*
* @example lazy loading
* // admin page - lazy loading of modul'/admin', async (navigation, abortSignal) => {
* // preload module
* const adminModule = await import('/modules/adminModule.js');
* // if not aborted
* if (!abortSignal.aborted) {
* // unregister app level route handle
* unRegister();
* // init module, which will register own handle for same route
* adminModule.adminModule(store);
* // navigate once again (with force flag)
* navigate(store, navigation.url, false, true);
* }
* });
*/
export const onNavigate = (
store: StoreonStore<any, RoutingEvents>,
route: string,
callback: RouteCallback): () => void => {
const id = handleId++;
routes[id] = {
id, callback, route, regexp: new RegExp(route),
};
const r = { id, route };
store.dispatch(REGISTER_EVENT, r);
return () => {
delete routes[id];
store.dispatch(UNREGISTER_EVENT, r);
};
};
/**
* Navigate to provided route.
*/
export const navigate = (
store: StoreonStore<any, RoutingEvents>,
url: string,
force?: boolean,
options?: any): Promise<void> => {
const id = navId++;
return new Promise((res, rej) => {
const u = store.on(POST_NAVIGATE_EVENT, (s, { navigation, error }) => {
if (id === navigation.id) {
u();
error ? rej(error) : res();
}
});
store.dispatch(PRE_NAVIGATE_EVENT, { navigation: { id, url, force, options } });
});
};
/**
* Cancel ongoing navigation.
*/
export const cancelNavigation = (store: StoreonStore<StateWithRouting, RoutingEvents>) => {
store.dispatch(CANCEL_EVENT);
};