aberdeen 2.0.0 → 2.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/src/route.d.ts +8 -8
- package/dist/src/route.js +57 -64
- package/dist/src/route.js.map +3 -3
- package/dist/src/server.d.ts +2 -2
- package/dist/src/server.js.map +1 -1
- package/dist-docs/Tutorial/index.html +2 -2
- package/dist-docs/aberdeen/Aberdeen/index.html +31 -31
- package/dist-docs/aberdeen/InitOptions/index.html +3 -3
- package/dist-docs/aberdeen/KeyToString/index.html +2 -2
- package/dist-docs/aberdeen/PromiseProxy/index.html +5 -5
- package/dist-docs/aberdeen/SortKeyType/index.html +2 -2
- package/dist-docs/aberdeen/TargetType/index.html +2 -2
- package/dist-docs/aberdeen/ValueRef/index.html +2 -2
- package/dist-docs/aberdeen/WindowLike/index.html +3 -3
- package/dist-docs/aberdeen/createA/index.html +2 -2
- package/dist-docs/aberdeen/index.html +2 -2
- package/dist-docs/assets/aberdeen/route.d.ts +8 -8
- package/dist-docs/assets/aberdeen/route.js +57 -64
- package/dist-docs/assets/aberdeen/route.js.map +3 -3
- package/dist-docs/assets/aberdeen/server.d.ts +2 -2
- package/dist-docs/assets/aberdeen/server.js.map +1 -1
- package/dist-docs/assets/navigation.js +1 -1
- package/dist-docs/assets/search.js +1 -1
- package/dist-docs/dispatcher/Dispatcher/index.html +4 -4
- package/dist-docs/dispatcher/MATCH_FAILED/index.html +2 -2
- package/dist-docs/dispatcher/MATCH_REST/index.html +2 -2
- package/dist-docs/dispatcher/index.html +2 -2
- package/dist-docs/hierarchy.html +1 -1
- package/dist-docs/index-1/index.html +2 -2
- package/dist-docs/index.html +2 -2
- package/dist-docs/media/CHANGELOG.md +1 -1
- package/dist-docs/modules.html +1 -1
- package/dist-docs/prediction/applyCanon/index.html +2 -2
- package/dist-docs/prediction/applyPrediction/index.html +2 -2
- package/dist-docs/prediction/index.html +2 -2
- package/dist-docs/route/RouteApi/index.html +14 -14
- package/dist-docs/route/{Route → RouteState}/index.html +9 -9
- package/dist-docs/route/{init → createRoute}/index.html +2 -2
- package/dist-docs/route/index.html +4 -4
- package/dist-docs/server/RenderToStringOptions/index.html +4 -4
- package/dist-docs/server/RenderToStringResult/index.html +4 -4
- package/dist-docs/server/index.html +2 -2
- package/dist-docs/server/renderToString/index.html +2 -2
- package/dist-docs/sitemap.xml +36 -36
- package/dist-docs/transitions/grow/index.html +2 -2
- package/dist-docs/transitions/index.html +2 -2
- package/dist-docs/transitions/shrink/index.html +2 -2
- package/package.json +1 -1
- package/skill/aberdeen.md +45 -45
- package/skill/dispatcher.md +6 -6
- package/skill/prediction.md +3 -3
- package/skill/route.md +90 -90
- package/skill/transitions.md +3 -3
- package/src/route.ts +295 -319
- package/src/server.ts +2 -2
package/src/route.ts
CHANGED
|
@@ -3,7 +3,7 @@ type NavType = "load" | "back" | "forward" | "go" | "push";
|
|
|
3
3
|
/**
|
|
4
4
|
* The class for the global `route` object.
|
|
5
5
|
*/
|
|
6
|
-
export interface
|
|
6
|
+
export interface RouteState {
|
|
7
7
|
/** The current path of the URL as a string. For instance `"/"` or `"/users/123/feed"`. Paths are normalized to always start with a `/` and never end with a `/` (unless it's the root path). */
|
|
8
8
|
path: string;
|
|
9
9
|
/** An convenience array containing path segments, mapping to `path`. For instance `[]` (for `"/"`) or `['users', '123', 'feed']` (for `"/users/123/feed"`). */
|
|
@@ -26,7 +26,7 @@ export interface Route {
|
|
|
26
26
|
nav: NavType;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
type RouteTarget = string | (string|number)[] | Partial<Omit<Omit<
|
|
29
|
+
type RouteTarget = string | (string|number)[] | Partial<Omit<Omit<RouteState,"p">,"search"> & {
|
|
30
30
|
/** An convenience array containing path segments, mapping to `path`. For instance `[]` (for `"/"`) or `['users', 123, 'feed']` (for `"/users/123/feed"`). Values may be integers but will be converted to strings.*/
|
|
31
31
|
p: (string|number)[],
|
|
32
32
|
/** The query string interpreted as search parameters. So `"a=x&b=y"` becomes `{a: "x", b: "y", c: 42}`. Values may be integers but will be converted to strings. */
|
|
@@ -34,13 +34,13 @@ type RouteTarget = string | (string|number)[] | Partial<Omit<Omit<Route,"p">,"se
|
|
|
34
34
|
}>;
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* The router API returned by {@link
|
|
37
|
+
* The router API returned by {@link createRoute}, bound to a specific Aberdeen instance's window.
|
|
38
38
|
*/
|
|
39
39
|
export interface RouteApi {
|
|
40
40
|
/**
|
|
41
|
-
* The global {@link
|
|
41
|
+
* The global {@link RouteState} object reflecting the current URL and browser history state. Changes you make to this affect the current browser history item (modifying the URL if needed).
|
|
42
42
|
*/
|
|
43
|
-
current:
|
|
43
|
+
current: RouteState;
|
|
44
44
|
/**
|
|
45
45
|
* Configure logging on route changes.
|
|
46
46
|
* @param value `true` to enable logging to console, `false` to disable logging, or a custom logging function. Defaults to `false`.
|
|
@@ -51,7 +51,7 @@ export interface RouteApi {
|
|
|
51
51
|
*
|
|
52
52
|
* Note that this happens synchronously, immediately updating `route` and processing any reactive updates based on that.
|
|
53
53
|
*
|
|
54
|
-
* @param target A subset of the {@link
|
|
54
|
+
* @param target A subset of the {@link RouteState} properties to navigate to. If neither `p` nor `path` is given, the current path is used. For other properties, an empty/default value is assumed if not given. For convenience:
|
|
55
55
|
* - You may pass a string instead of an object, which is interpreted as the `path`.
|
|
56
56
|
* - You may pass an array instead of an object, which is interpreted as the `p` array.
|
|
57
57
|
* - If you pass `p`, it may contain numbers, which will be converted to strings.
|
|
@@ -103,7 +103,7 @@ export interface RouteApi {
|
|
|
103
103
|
*
|
|
104
104
|
* Consider using {@link up} to go up in the path hierarchy.
|
|
105
105
|
*
|
|
106
|
-
* @param target The target route to go back to. May be a subset of {@link
|
|
106
|
+
* @param target The target route to go back to. May be a subset of {@link RouteState}, or a string (for `path`), or an array of strings (for `p`).
|
|
107
107
|
*/
|
|
108
108
|
back(target?: RouteTarget): void;
|
|
109
109
|
/**
|
|
@@ -116,17 +116,6 @@ export interface RouteApi {
|
|
|
116
116
|
up(stripCount?: number): void;
|
|
117
117
|
/** @internal */
|
|
118
118
|
reset(): void;
|
|
119
|
-
/**
|
|
120
|
-
* Run the router's browser-dependent setup: sync the `route` proxy to the current URL,
|
|
121
|
-
* start listening for `popstate`, and start the observers that keep the browser URL in sync
|
|
122
|
-
* with `route`. Safe to call multiple times (subsequent calls are no-ops).
|
|
123
|
-
*
|
|
124
|
-
* This runs automatically on import in a browser. Outside a browser it is a no-op until a
|
|
125
|
-
* window has been provided via `setWindow`, after which you should call it yourself (or call
|
|
126
|
-
* `reset` to re-sync to a new request URL). See the `aberdeen/server` SSR entry point.
|
|
127
|
-
* @internal
|
|
128
|
-
*/
|
|
129
|
-
initRouter(): void;
|
|
130
119
|
/**
|
|
131
120
|
* Restore and store the vertical and horizontal scroll position for
|
|
132
121
|
* the parent element to the page state.
|
|
@@ -163,361 +152,348 @@ export interface RouteApi {
|
|
|
163
152
|
*
|
|
164
153
|
* @param A An Aberdeen instance (the default export of `aberdeen`, or one from `init`).
|
|
165
154
|
*/
|
|
166
|
-
export function
|
|
155
|
+
export function createRoute(A: any): RouteApi {
|
|
167
156
|
|
|
168
|
-
|
|
157
|
+
const window = A.window;
|
|
158
|
+
const location = window.location;
|
|
159
|
+
const history = window.history;
|
|
169
160
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
161
|
+
let log: (...args: any) => void = () => {};
|
|
162
|
+
|
|
163
|
+
function setLog(value: boolean | ((...args: any[]) => void)) {
|
|
164
|
+
if (value === true) {
|
|
165
|
+
log = console.log.bind(console, 'aberdeen router');
|
|
166
|
+
} else if (value === false) {
|
|
167
|
+
log = () => {};
|
|
168
|
+
} else {
|
|
169
|
+
log = value;
|
|
170
|
+
}
|
|
177
171
|
}
|
|
178
|
-
}
|
|
179
172
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return toCanonRoute({
|
|
189
|
-
path: locationE().pathname,
|
|
190
|
-
hash: locationE().hash,
|
|
191
|
-
search: Object.fromEntries(new URLSearchParams(locationE().search)),
|
|
192
|
-
state: historyE().state?.state || {},
|
|
193
|
-
}, "load", (historyE().state?.stack?.length || 0) + 1);
|
|
194
|
-
}
|
|
173
|
+
function getRouteFromBrowser(): RouteState {
|
|
174
|
+
return toCanonRoute({
|
|
175
|
+
path: location.pathname,
|
|
176
|
+
hash: location.hash,
|
|
177
|
+
search: Object.fromEntries(new URLSearchParams(location.search)),
|
|
178
|
+
state: history.state?.state || {},
|
|
179
|
+
}, "load", (history.state?.stack?.length || 0) + 1);
|
|
180
|
+
}
|
|
195
181
|
|
|
196
|
-
/**
|
|
197
|
-
* Deep compare `a` and `b`. If `partial` is true, objects contained in `b` may be a subset
|
|
198
|
-
* of their counterparts in `a` and still be considered equal.
|
|
199
|
-
*/
|
|
200
|
-
function equal(a: any, b: any, partial: boolean): boolean {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Deep compare `a` and `b`. If `partial` is true, objects contained in `b` may be a subset
|
|
184
|
+
* of their counterparts in `a` and still be considered equal.
|
|
185
|
+
*/
|
|
186
|
+
function equal(a: any, b: any, partial: boolean): boolean {
|
|
187
|
+
if (a===b) return true;
|
|
188
|
+
if (typeof a !== "object" || !a || typeof b !== "object" || !b) return false; // otherwise they would have been equal
|
|
189
|
+
if (a.constructor !== b.constructor) return false;
|
|
190
|
+
if (b instanceof Array) {
|
|
191
|
+
if (a.length !== b.length) return false;
|
|
192
|
+
for(let i = 0; i < b.length; i++) {
|
|
193
|
+
if (!equal(a[i], b[i], partial)) return false;
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
for(const k of Object.keys(b)) {
|
|
197
|
+
if (!equal(a[k], b[k], partial)) return false;
|
|
198
|
+
}
|
|
199
|
+
if (!partial) {
|
|
200
|
+
for(const k of Object.keys(a)) {
|
|
201
|
+
if (!b.hasOwnProperty(k)) return false;
|
|
202
|
+
}
|
|
216
203
|
}
|
|
217
204
|
}
|
|
205
|
+
return true;
|
|
218
206
|
}
|
|
219
|
-
return true;
|
|
220
|
-
}
|
|
221
207
|
|
|
222
|
-
function getUrl(target:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
208
|
+
function getUrl(target: RouteState) {
|
|
209
|
+
const search = new URLSearchParams(target.search).toString();
|
|
210
|
+
return (search ? `${target.path}?${search}` : target.path) + target.hash;
|
|
211
|
+
}
|
|
226
212
|
|
|
227
|
-
function toCanonRoute(target: Partial<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
213
|
+
function toCanonRoute(target: Partial<RouteState>, nav: NavType, depth: number): RouteState {
|
|
214
|
+
let path = target.path || (target.p || []).join("/") || "/";
|
|
215
|
+
path = (""+path).replace(/\/+$/, "");
|
|
216
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
path,
|
|
220
|
+
hash: target.hash && target.hash !=="#" ? (target.hash.startsWith("#") ? target.hash : "#" + target.hash) : "",
|
|
221
|
+
p: path.length > 1 ? path.slice(1).replace(/\/+$/, "").split("/") : [],
|
|
222
|
+
nav,
|
|
223
|
+
search: typeof target.search === 'object' && target.search ? A.clone(target.search) : {},
|
|
224
|
+
state: typeof target.state === 'object' && target.state ? A.clone(target.state) : {},
|
|
225
|
+
depth,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
242
228
|
|
|
243
229
|
|
|
244
|
-
function targetToPartial(target: RouteTarget, undefinedOnExternal: true): Partial<
|
|
245
|
-
function targetToPartial(target: RouteTarget): Partial<
|
|
230
|
+
function targetToPartial(target: RouteTarget, undefinedOnExternal: true): Partial<RouteState> | undefined;
|
|
231
|
+
function targetToPartial(target: RouteTarget): Partial<RouteState>;
|
|
246
232
|
|
|
247
233
|
|
|
248
|
-
function targetToPartial(target: RouteTarget, undefinedOnExternal: boolean=false) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
234
|
+
function targetToPartial(target: RouteTarget, undefinedOnExternal: boolean=false) {
|
|
235
|
+
// Convert shortcut values to objects
|
|
236
|
+
if (typeof target === 'string') {
|
|
237
|
+
// Parse using URL to handle both absolute and relative paths correctly
|
|
238
|
+
const url = new URL(target, location.href);
|
|
239
|
+
if (url.host !== location.host) {
|
|
240
|
+
if (undefinedOnExternal) return;
|
|
241
|
+
throw new Error(`Unexpected external URL: ${url.host} != ${location.host}`);
|
|
242
|
+
}
|
|
243
|
+
target = {
|
|
244
|
+
path: url.pathname,
|
|
245
|
+
search: Object.fromEntries(url.searchParams),
|
|
246
|
+
hash: url.hash,
|
|
247
|
+
};
|
|
248
|
+
} else if (target instanceof Array) {
|
|
249
|
+
target = {p: target};
|
|
256
250
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// Convert numbers in p and search to strings
|
|
266
|
-
if (target.p) {
|
|
267
|
-
target.p = target.p.map(String);
|
|
268
|
-
}
|
|
269
|
-
if (target.search) {
|
|
270
|
-
for(const key of Object.keys(target.search)) {
|
|
271
|
-
target.search[key] = String(target.search[key]);
|
|
251
|
+
// Convert numbers in p and search to strings
|
|
252
|
+
if (target.p) {
|
|
253
|
+
target.p = target.p.map(String);
|
|
254
|
+
}
|
|
255
|
+
if (target.search) {
|
|
256
|
+
for(const key of Object.keys(target.search)) {
|
|
257
|
+
target.search[key] = String(target.search[key]);
|
|
258
|
+
}
|
|
272
259
|
}
|
|
260
|
+
return target as Partial<RouteState>;
|
|
273
261
|
}
|
|
274
|
-
return target as Partial<Route>;
|
|
275
|
-
}
|
|
276
262
|
|
|
277
263
|
|
|
278
|
-
function go(target: RouteTarget, nav: NavType = "go"): void {
|
|
279
|
-
|
|
280
|
-
|
|
264
|
+
function go(target: RouteTarget, nav: NavType = "go"): void {
|
|
265
|
+
pendingGoOffset = 0;
|
|
266
|
+
const stack: string[] = history.state?.stack || [];
|
|
281
267
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
268
|
+
prevStack = stack.concat(JSON.stringify(A.unproxy(current)));
|
|
269
|
+
|
|
270
|
+
const newRoute: RouteState = toCanonRoute(targetToPartial(target), nav, prevStack.length + 1);
|
|
271
|
+
A.copy(current, newRoute);
|
|
272
|
+
|
|
273
|
+
log(nav, newRoute);
|
|
274
|
+
history.pushState({state: newRoute.state, stack: prevStack}, "", getUrl(newRoute));
|
|
275
|
+
|
|
276
|
+
A.runQueue();
|
|
277
|
+
}
|
|
292
278
|
|
|
293
|
-
function matchCurrent(target: RouteTarget): boolean {
|
|
294
|
-
|
|
295
|
-
|
|
279
|
+
function matchCurrent(target: RouteTarget): boolean {
|
|
280
|
+
const partial = targetToPartial(target, true);
|
|
281
|
+
if (!partial) return false; // External link
|
|
296
282
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
283
|
+
if (partial.path != null || partial.p != null) {
|
|
284
|
+
let path = partial.path || (partial.p || []).join("/") || "/";
|
|
285
|
+
path = (""+path).replace(/\/+$/, "");
|
|
286
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
287
|
+
if (!currentRouteParts[path]) return false;
|
|
288
|
+
}
|
|
303
289
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
290
|
+
if (partial.search) {
|
|
291
|
+
for(const [k,v] of Object.entries(partial.search)) {
|
|
292
|
+
if (!currentRouteParts[`${k}=${v}`]) return false;
|
|
293
|
+
}
|
|
307
294
|
}
|
|
295
|
+
return true;
|
|
308
296
|
}
|
|
309
|
-
return true;
|
|
310
|
-
}
|
|
311
297
|
|
|
312
|
-
function push(target: RouteTarget, nav?: NavType): void {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
298
|
+
function push(target: RouteTarget, nav?: NavType): void {
|
|
299
|
+
const c = A.clone(A.unproxy(current));
|
|
300
|
+
A.merge(c, targetToPartial(target));
|
|
301
|
+
go(c, nav || c.nav);
|
|
302
|
+
}
|
|
317
303
|
|
|
318
|
-
function back(target: RouteTarget = {}): void {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
304
|
+
function back(target: RouteTarget = {}): void {
|
|
305
|
+
const partial = targetToPartial(target);
|
|
306
|
+
const stack: string[] = history.state?.stack || [];
|
|
307
|
+
const effectiveLen = stack.length + pendingGoOffset;
|
|
308
|
+
for(let i = effectiveLen - 1; i >= 0; i--) {
|
|
309
|
+
const histRoute: RouteState = JSON.parse(stack[i]);
|
|
310
|
+
if (equal(histRoute, partial, true)) {
|
|
311
|
+
const pages = i - effectiveLen;
|
|
312
|
+
log(`back`, pages, histRoute);
|
|
313
|
+
scheduleHistoryGo(pages);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
329
316
|
}
|
|
330
|
-
}
|
|
331
317
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
318
|
+
const newRoute = toCanonRoute(partial, "back", effectiveLen + 1);
|
|
319
|
+
log(`back not found, replacing`, partial);
|
|
320
|
+
A.copy(current, newRoute);
|
|
321
|
+
}
|
|
336
322
|
|
|
337
|
-
function up(stripCount: number = 1): void {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
323
|
+
function up(stripCount: number = 1): void {
|
|
324
|
+
const currentP = A.unproxy(current).p;
|
|
325
|
+
const stack: string[] = history.state?.stack || [];
|
|
326
|
+
const effectiveLen = stack.length + pendingGoOffset;
|
|
327
|
+
for(let i = effectiveLen - 1; i >= 0; i--) {
|
|
328
|
+
const histRoute: RouteState = JSON.parse(stack[i]);
|
|
329
|
+
if (histRoute.p.length < currentP.length && equal(histRoute.p, currentP.slice(0, histRoute.p.length), false)) {
|
|
330
|
+
// This route is shorter and matches the start of the current path
|
|
331
|
+
log(`up to ${i+1} / ${effectiveLen}`, histRoute);
|
|
332
|
+
scheduleHistoryGo(i - effectiveLen);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
348
335
|
}
|
|
336
|
+
// Replace current route with /
|
|
337
|
+
const newRoute = toCanonRoute({p: currentP.slice(0, currentP.length - stripCount)}, "back", effectiveLen + 1);
|
|
338
|
+
log(`up not found, replacing`, newRoute);
|
|
339
|
+
A.copy(current, newRoute);
|
|
349
340
|
}
|
|
350
|
-
// Replace current route with /
|
|
351
|
-
const newRoute = toCanonRoute({p: currentP.slice(0, currentP.length - stripCount)}, "back", effectiveLen + 1);
|
|
352
|
-
log(`up not found, replacing`, newRoute);
|
|
353
|
-
A.copy(current, newRoute);
|
|
354
|
-
}
|
|
355
341
|
|
|
356
342
|
|
|
357
|
-
let prevStack: string[];
|
|
343
|
+
let prevStack: string[];
|
|
358
344
|
|
|
359
|
-
// Track pending historyE().go() offset. Multiple back()/up() calls before the event loop
|
|
360
|
-
// processes them are batched into a single historyE().go() via queueMicrotask.
|
|
361
|
-
let pendingGoOffset = 0;
|
|
345
|
+
// Track pending historyE().go() offset. Multiple back()/up() calls before the event loop
|
|
346
|
+
// processes them are batched into a single historyE().go() via queueMicrotask.
|
|
347
|
+
let pendingGoOffset = 0;
|
|
362
348
|
|
|
363
|
-
function scheduleHistoryGo(delta: number) {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const current: Route = A.proxy({}) as Route;
|
|
349
|
+
function scheduleHistoryGo(delta: number) {
|
|
350
|
+
pendingGoOffset += delta;
|
|
351
|
+
(A.window.setTimeout ?? setTimeout)(() => {
|
|
352
|
+
if (pendingGoOffset) {
|
|
353
|
+
const offset = pendingGoOffset;
|
|
354
|
+
pendingGoOffset = 0;
|
|
355
|
+
history.go(offset);
|
|
356
|
+
}
|
|
357
|
+
}, 0);
|
|
358
|
+
}
|
|
375
359
|
|
|
376
|
-
|
|
377
|
-
// Used by matchCurrent to subscribe only to relevant route changes.
|
|
378
|
-
const currentRouteParts = A.proxy({} as Record<string,true>);
|
|
360
|
+
const current: RouteState = A.proxy({}) as RouteState;
|
|
379
361
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
* */
|
|
384
|
-
function reset() {
|
|
385
|
-
prevStack = historyE().state?.stack || [];
|
|
386
|
-
const initRoute = getRouteFromBrowser();
|
|
387
|
-
log('initial', initRoute);
|
|
388
|
-
A.copy(A.unproxy(current), initRoute);
|
|
389
|
-
}
|
|
390
|
-
let routerInited = false;
|
|
362
|
+
// Proxied object with keys for the current path and "k=v" search pairs.
|
|
363
|
+
// Used by matchCurrent to subscribe only to relevant route changes.
|
|
364
|
+
const currentRouteParts = A.proxy({} as Record<string,true>);
|
|
391
365
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Reset the router to its initial state, based on the current browser state. Intended for testing purposes only.
|
|
368
|
+
* @internal
|
|
369
|
+
* */
|
|
370
|
+
function reset() {
|
|
371
|
+
prevStack = history.state?.stack || [];
|
|
372
|
+
const initRoute = getRouteFromBrowser();
|
|
373
|
+
log('initial', initRoute);
|
|
374
|
+
A.copy(A.unproxy(current), initRoute);
|
|
375
|
+
}
|
|
395
376
|
|
|
396
377
|
reset();
|
|
397
378
|
|
|
398
379
|
// Handle browser history back and forward
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
380
|
+
window.addEventListener("popstate", function(event: PopStateEvent) {
|
|
381
|
+
pendingGoOffset = 0;
|
|
382
|
+
const newRoute = getRouteFromBrowser();
|
|
383
|
+
|
|
384
|
+
// If the stack length changes, and at least the top-most shared entry is the same,
|
|
385
|
+
// we'll interpret this as a "back" or "forward" navigation.
|
|
386
|
+
const stack: string[] = history.state?.stack || [];
|
|
387
|
+
if (stack.length !== prevStack.length) {
|
|
388
|
+
const maxIndex = Math.min(prevStack.length, stack.length) - 1;
|
|
389
|
+
if (maxIndex < 0 || stack[maxIndex] === prevStack[maxIndex]) {
|
|
390
|
+
newRoute.nav = stack.length < prevStack.length ? "back" : "forward";
|
|
391
|
+
}
|
|
410
392
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
log('popstate', newRoute);
|
|
416
|
-
A.copy(current, newRoute);
|
|
417
|
-
|
|
418
|
-
A.runQueue();
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
// Make sure these observers are never cleaned up, not even by `unmountAll`.
|
|
422
|
-
A.leakScope(() => {
|
|
423
|
-
// Sync `p` to `path`. We need to do this in a separate, higher-priority observer,
|
|
424
|
-
// so that setting `route.p` will not be immediately overruled by the pre-existing `route.path`.
|
|
425
|
-
A(() => {
|
|
426
|
-
current.path = "/" + Array.from(current.p).join("/");
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
// Do a replaceState based on changes to A.proxy
|
|
430
|
-
A(() => {
|
|
431
|
-
// First normalize `route`
|
|
432
|
-
const stack = historyE().state?.stack || [];
|
|
433
|
-
const newRoute = toCanonRoute(current, A.unproxy(current).nav, stack.length + 1);
|
|
393
|
+
// else nav will be "load"
|
|
394
|
+
|
|
395
|
+
prevStack = stack;
|
|
396
|
+
log('popstate', newRoute);
|
|
434
397
|
A.copy(current, newRoute);
|
|
435
398
|
|
|
436
|
-
|
|
437
|
-
const state = {state: newRoute.state, stack};
|
|
438
|
-
const url = getUrl(newRoute);
|
|
439
|
-
if (url !== locationE().pathname + locationE().search + locationE().hash || !equal(historyE().state, state, false)) {
|
|
440
|
-
log('replaceState', newRoute, state, url);
|
|
441
|
-
historyE().replaceState(state, "", url);
|
|
442
|
-
}
|
|
399
|
+
A.runQueue();
|
|
443
400
|
});
|
|
444
401
|
|
|
445
|
-
//
|
|
446
|
-
A(() => {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
|
|
402
|
+
// Make sure these observers are never cleaned up, not even by `unmountAll`.
|
|
403
|
+
A.leakScope(() => {
|
|
404
|
+
// Sync `p` to `path`. We need to do this in a separate, higher-priority observer,
|
|
405
|
+
// so that setting `route.p` will not be immediately overruled by the pre-existing `route.path`.
|
|
406
|
+
A(() => {
|
|
407
|
+
current.path = "/" + Array.from(current.p).join("/");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Do a replaceState based on changes to A.proxy
|
|
411
|
+
A(() => {
|
|
412
|
+
// First normalize `route`
|
|
413
|
+
const stack = history.state?.stack || [];
|
|
414
|
+
const newRoute = toCanonRoute(current, A.unproxy(current).nav, stack.length + 1);
|
|
415
|
+
A.copy(current, newRoute);
|
|
416
|
+
|
|
417
|
+
// Then replace the current browser state if something actually changed
|
|
418
|
+
const state = {state: newRoute.state, stack};
|
|
419
|
+
const url = getUrl(newRoute);
|
|
420
|
+
if (url !== location.pathname + location.search + location.hash || !equal(history.state, state, false)) {
|
|
421
|
+
log('replaceState', newRoute, state, url);
|
|
422
|
+
history.replaceState(state, "", url);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Keep currentRouteParts in sync with the current path and search params.
|
|
427
|
+
A(() => {
|
|
428
|
+
const n = {} as Record<string,true>;
|
|
429
|
+
n[current.path] = true;
|
|
430
|
+
for(const [k,v] of Object.entries(current.search)) {
|
|
431
|
+
n[`${k}=${v}`] = true;
|
|
432
|
+
}
|
|
433
|
+
A.copy(currentRouteParts, n);
|
|
434
|
+
});
|
|
453
435
|
});
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// Initialise immediately in a browser; a no-op (until `setWindow`) on the server.
|
|
458
|
-
initRouter();
|
|
459
436
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
A('scroll=', onScroll);
|
|
464
|
-
|
|
465
|
-
const restore = A.unproxy(current).state.scroll?.[name];
|
|
466
|
-
if (restore) {
|
|
467
|
-
log("restoring scroll", name, restore);
|
|
468
|
-
Object.assign(el, restore);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function onScroll() {
|
|
472
|
-
(current.state.scroll ||= {})[name] = {
|
|
473
|
-
scrollTop: el.scrollTop,
|
|
474
|
-
scrollLeft: el.scrollLeft,
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
function interceptLinks() {
|
|
480
|
-
A({
|
|
481
|
-
click: handleEvent,
|
|
482
|
-
keydown: handleKeyEvent,
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
function handleKeyEvent(e: KeyboardEvent) {
|
|
486
|
-
if (e.key === "Enter") {
|
|
487
|
-
handleEvent(e);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function handleEvent(e: Event) {
|
|
492
|
-
// Find the closest <a> tag
|
|
493
|
-
let target = e.target as HTMLElement | null;
|
|
494
|
-
while (target && target.tagName?.toUpperCase() !== "A") {
|
|
495
|
-
target = target.parentElement;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
if (!target) return;
|
|
437
|
+
function persistScroll(name = "main") {
|
|
438
|
+
const el = A()!;
|
|
439
|
+
A('scroll=', onScroll);
|
|
499
440
|
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
// Skip hash-only links
|
|
506
|
-
if (href.startsWith("#")) return;
|
|
507
|
-
|
|
508
|
-
// Skip if it has a protocol or is protocol-relative (// or contains : before any / ? #)
|
|
509
|
-
if (href.startsWith("//") || /^[^/?#]+:/.test(href)) return;
|
|
441
|
+
const restore = A.unproxy(current).state.scroll?.[name];
|
|
442
|
+
if (restore) {
|
|
443
|
+
log("restoring scroll", name, restore);
|
|
444
|
+
Object.assign(el, restore);
|
|
445
|
+
}
|
|
510
446
|
|
|
511
|
-
|
|
512
|
-
|
|
447
|
+
function onScroll() {
|
|
448
|
+
(current.state.scroll ||= {})[name] = {
|
|
449
|
+
scrollTop: el.scrollTop,
|
|
450
|
+
scrollLeft: el.scrollLeft,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function interceptLinks() {
|
|
456
|
+
A({
|
|
457
|
+
click: handleEvent,
|
|
458
|
+
keydown: handleKeyEvent,
|
|
459
|
+
});
|
|
513
460
|
|
|
514
|
-
|
|
515
|
-
|
|
461
|
+
function handleKeyEvent(e: KeyboardEvent) {
|
|
462
|
+
if (e.key === "Enter") {
|
|
463
|
+
handleEvent(e);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
516
466
|
|
|
517
|
-
e
|
|
518
|
-
|
|
467
|
+
function handleEvent(e: Event) {
|
|
468
|
+
// Find the closest <a> tag
|
|
469
|
+
let target = e.target as HTMLElement | null;
|
|
470
|
+
while (target && target.tagName?.toUpperCase() !== "A") {
|
|
471
|
+
target = target.parentElement;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (!target) return;
|
|
475
|
+
|
|
476
|
+
const anchor = target as HTMLAnchorElement;
|
|
477
|
+
const href = anchor.getAttribute("href");
|
|
478
|
+
|
|
479
|
+
if (!href) return;
|
|
480
|
+
|
|
481
|
+
// Skip hash-only links
|
|
482
|
+
if (href.startsWith("#")) return;
|
|
483
|
+
|
|
484
|
+
// Skip if it has a protocol or is protocol-relative (// or contains : before any / ? #)
|
|
485
|
+
if (href.startsWith("//") || /^[^/?#]+:/.test(href)) return;
|
|
486
|
+
|
|
487
|
+
// Skip if the link has target or download attribute
|
|
488
|
+
if (anchor.getAttribute("target") || anchor.getAttribute("download")) return;
|
|
489
|
+
|
|
490
|
+
// Skip if modifier keys are pressed (Ctrl/Cmd click to open in new tab)
|
|
491
|
+
if (typeof MouseEvent !== 'undefined' && e instanceof MouseEvent && (e.ctrlKey || e.metaKey || e.shiftKey)) return;
|
|
492
|
+
|
|
493
|
+
e.preventDefault();
|
|
494
|
+
go(href);
|
|
495
|
+
}
|
|
519
496
|
}
|
|
520
|
-
}
|
|
521
497
|
|
|
522
|
-
return { current, setLog, go, matchCurrent, push, back, up, reset,
|
|
498
|
+
return { current, setLog, go, matchCurrent, push, back, up, reset, persistScroll, interceptLinks } as RouteApi;
|
|
523
499
|
}
|