@excom/kit-router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.rush/temp/chunked-rush-logs/kit-router.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +6 -0
- package/index.ts +422 -0
- package/package.json +39 -0
- package/rush-logs/kit-router.apply-exports.cache.log +1 -0
- package/rush-logs/kit-router.apply-exports.log +1 -0
- package/support/tests/kit-router-history.test.ts +344 -0
- package/support/tests/kit-router.test.ts +200 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
|
package/config/rig.json
ADDED
package/index.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { hashObject } from "@excom/hash-object";
|
|
2
|
+
import { KitLogger } from "@excom/kit-logger";
|
|
3
|
+
import { requestIdleCb } from "@excom/kit-shims";
|
|
4
|
+
|
|
5
|
+
export interface KitRouteState {
|
|
6
|
+
id: string;
|
|
7
|
+
url: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
isInit?: boolean;
|
|
10
|
+
scrollX?: number;
|
|
11
|
+
scrollY?: number;
|
|
12
|
+
ttypes?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface KitChangeStateOptions {
|
|
16
|
+
url: string;
|
|
17
|
+
ttypes?: string[];
|
|
18
|
+
title?: string;
|
|
19
|
+
scrollY?: number;
|
|
20
|
+
scrollX?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface KitRouteData {
|
|
24
|
+
previous: KitRouteState | null;
|
|
25
|
+
active: KitRouteState;
|
|
26
|
+
next: KitRouteState | null;
|
|
27
|
+
all: KitRouteState[];
|
|
28
|
+
params: Record<string, string> | null;
|
|
29
|
+
match: Array<any> | null;
|
|
30
|
+
move: null | "push" | "replace" | "back" | "forward";
|
|
31
|
+
event?: {
|
|
32
|
+
hasUAVisualTransition: boolean;
|
|
33
|
+
};
|
|
34
|
+
route: KitRoute;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface KitRouteOpts {
|
|
38
|
+
matchNested?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type KitRouteHandler = (data: KitRouteData) => void;
|
|
42
|
+
|
|
43
|
+
export class KitRoute {
|
|
44
|
+
key: string | RegExp;
|
|
45
|
+
regex: RegExp;
|
|
46
|
+
paramNames: string[];
|
|
47
|
+
handler: KitRouteHandler;
|
|
48
|
+
opts: KitRouteOpts;
|
|
49
|
+
constructor(
|
|
50
|
+
key: string | RegExp,
|
|
51
|
+
handler: KitRouteHandler,
|
|
52
|
+
opts: KitRouteOpts = {}
|
|
53
|
+
) {
|
|
54
|
+
if (!key || !handler) throw new Error("Invalid route or handler");
|
|
55
|
+
this.key = key;
|
|
56
|
+
this.handler = handler;
|
|
57
|
+
this.opts = opts;
|
|
58
|
+
this.paramNames = [];
|
|
59
|
+
if (typeof key === "string") {
|
|
60
|
+
const expression = key
|
|
61
|
+
.replace(/([:*])(\w+)/g, (_full, _dots, name) => {
|
|
62
|
+
// `_full` and `_dots` are unused
|
|
63
|
+
this.paramNames.push(name);
|
|
64
|
+
return "([^/]+)";
|
|
65
|
+
})
|
|
66
|
+
.replace(/\*/g, "(?:.*)");
|
|
67
|
+
const endOfPath = opts.matchNested ? "/" : "$";
|
|
68
|
+
this.regex = new RegExp(`^${expression}${endOfPath}`);
|
|
69
|
+
} else if (key instanceof RegExp) {
|
|
70
|
+
this.regex = key;
|
|
71
|
+
} else {
|
|
72
|
+
throw new Error("Invalid route key type. Must be string or RegExp.");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
public match(pathname: string) {
|
|
76
|
+
const { regex, paramNames } = this;
|
|
77
|
+
const match = pathname.match(regex);
|
|
78
|
+
return {
|
|
79
|
+
match,
|
|
80
|
+
params:
|
|
81
|
+
match && match.length > 0
|
|
82
|
+
? this.collectRouteParams(match, paramNames)
|
|
83
|
+
: null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
private collectRouteParams(match: string[], paramNames: string[]) {
|
|
87
|
+
try {
|
|
88
|
+
return match.slice(1, match.length).reduce((params, value, index) => {
|
|
89
|
+
params[paramNames[index]] = decodeURIComponent(value);
|
|
90
|
+
|
|
91
|
+
return params;
|
|
92
|
+
}, {});
|
|
93
|
+
} catch (_) {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* History-backed router. Caps retained states so sessionStorage stays bounded.
|
|
101
|
+
*/
|
|
102
|
+
export class KitRouter {
|
|
103
|
+
// ~0.2kb per data-heavy state → ~5mb at this cap in sessionStorage
|
|
104
|
+
public DEFAULT_MAX_STATES = 25000;
|
|
105
|
+
public MAX_STATES = 25000;
|
|
106
|
+
protected routes: KitRoute[] = [];
|
|
107
|
+
protected states: KitRouteState[];
|
|
108
|
+
protected currentTempData: {
|
|
109
|
+
move: KitRouteData["move"];
|
|
110
|
+
event?: KitRouteData["event"];
|
|
111
|
+
};
|
|
112
|
+
protected currentStateId: string | null;
|
|
113
|
+
private handlePopState: (e: PopStateEvent) => void;
|
|
114
|
+
|
|
115
|
+
constructor() {
|
|
116
|
+
const sessionData = this.getInitSessionData();
|
|
117
|
+
this.states = sessionData.states;
|
|
118
|
+
this.currentTempData = sessionData.currentTempData;
|
|
119
|
+
this.currentStateId = sessionData.currentStateId;
|
|
120
|
+
this.routes = [];
|
|
121
|
+
this.handlePopState = (e: PopStateEvent) => {
|
|
122
|
+
if (this.currentStateId) {
|
|
123
|
+
const lastPopStateIndex = this.getStateIndex(this.currentStateId);
|
|
124
|
+
const currentStateIndex = this.getStateIndex(history.state?.id);
|
|
125
|
+
if (lastPopStateIndex < currentStateIndex) {
|
|
126
|
+
this.setScrollData(this.states[lastPopStateIndex]);
|
|
127
|
+
this.setSessionData({
|
|
128
|
+
currentTempData: {
|
|
129
|
+
move: "forward",
|
|
130
|
+
event: {
|
|
131
|
+
hasUAVisualTransition: e.hasUAVisualTransition,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
} else if (lastPopStateIndex > currentStateIndex) {
|
|
136
|
+
this.setScrollData(this.states[lastPopStateIndex]);
|
|
137
|
+
this.setSessionData({
|
|
138
|
+
currentTempData: {
|
|
139
|
+
move: "back",
|
|
140
|
+
event: {
|
|
141
|
+
hasUAVisualTransition: e.hasUAVisualTransition,
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
// TODO: set scroll data here?
|
|
148
|
+
this.setSessionData({
|
|
149
|
+
currentTempData: {
|
|
150
|
+
move: "back",
|
|
151
|
+
event: {
|
|
152
|
+
hasUAVisualTransition: e.hasUAVisualTransition,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
this.setSessionData({
|
|
158
|
+
// Falls back to the initial state id when history has no recognized id
|
|
159
|
+
currentStateId: this.getActiveState(
|
|
160
|
+
this.getStateIndex(history.state?.id)
|
|
161
|
+
).id,
|
|
162
|
+
});
|
|
163
|
+
this.locationChanged();
|
|
164
|
+
};
|
|
165
|
+
window.addEventListener("popstate", this.handlePopState);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public destroy() {
|
|
169
|
+
window.removeEventListener("popstate", this.handlePopState);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
public pushState(changeStateOptions: KitChangeStateOptions) {
|
|
173
|
+
this.beforePushState();
|
|
174
|
+
const state = buildState(changeStateOptions);
|
|
175
|
+
this.setSessionData({
|
|
176
|
+
states: [...this.states, state],
|
|
177
|
+
currentStateId: state.id,
|
|
178
|
+
currentTempData: {
|
|
179
|
+
move: "push",
|
|
180
|
+
event: undefined,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
history.pushState(
|
|
184
|
+
{ id: state.id },
|
|
185
|
+
changeStateOptions.title ?? document.title,
|
|
186
|
+
changeStateOptions.url
|
|
187
|
+
);
|
|
188
|
+
this.locationChanged();
|
|
189
|
+
}
|
|
190
|
+
public replaceState(changeStateOptions: KitChangeStateOptions) {
|
|
191
|
+
this.beforePushState();
|
|
192
|
+
const state = buildState(changeStateOptions);
|
|
193
|
+
this.setSessionData({
|
|
194
|
+
states: [...this.states.slice(0, -1), state],
|
|
195
|
+
currentStateId: state.id,
|
|
196
|
+
currentTempData: {
|
|
197
|
+
move: "replace",
|
|
198
|
+
event: undefined,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
this._replaceState(changeStateOptions.url, changeStateOptions.title, {
|
|
202
|
+
id: state.id,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
private _replaceState(url, title?, historyState = history.state) {
|
|
206
|
+
history.replaceState(historyState, title ?? document.title, url);
|
|
207
|
+
this.locationChanged();
|
|
208
|
+
}
|
|
209
|
+
public canGoBack() {
|
|
210
|
+
const activeStateIndex = this.getStateIndex(history.state?.id);
|
|
211
|
+
if (activeStateIndex > 0) {
|
|
212
|
+
return true;
|
|
213
|
+
} else {
|
|
214
|
+
const activeState = this.getActiveState(activeStateIndex);
|
|
215
|
+
// Past `MAX_STATES` the oldest (init) state is gone; back still works, just without metadata.
|
|
216
|
+
return !activeState.isInit;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
public canGoForward() {
|
|
220
|
+
const activeStateIndex = this.getStateIndex(history.state?.id);
|
|
221
|
+
const nextState = this.getNextState(activeStateIndex);
|
|
222
|
+
return !!nextState;
|
|
223
|
+
}
|
|
224
|
+
public back(stateIndex = -1) {
|
|
225
|
+
history.go(stateIndex);
|
|
226
|
+
}
|
|
227
|
+
public forward(stateIndex = 1) {
|
|
228
|
+
history.go(stateIndex);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private locationChanged(routes = this.routes) {
|
|
232
|
+
const pathname = decodeURI(location.pathname);
|
|
233
|
+
const search = location.search;
|
|
234
|
+
const hash = location.hash;
|
|
235
|
+
|
|
236
|
+
if (this.shouldStripSlash(pathname)) {
|
|
237
|
+
// Private `_replaceState` so we don't track this slash-strip replace
|
|
238
|
+
this._replaceState(
|
|
239
|
+
pathname.slice(0, pathname.length - 1) + search + hash
|
|
240
|
+
);
|
|
241
|
+
} else {
|
|
242
|
+
const activeStateIndex = this.getStateIndex(history.state?.id);
|
|
243
|
+
const previous = this.getPreviousState(activeStateIndex);
|
|
244
|
+
const active = this.getActiveState(activeStateIndex);
|
|
245
|
+
const next = this.getNextState(activeStateIndex);
|
|
246
|
+
routes.forEach((route) => {
|
|
247
|
+
const { match, params } = route.match(pathname);
|
|
248
|
+
const data: KitRouteData = {
|
|
249
|
+
previous,
|
|
250
|
+
active,
|
|
251
|
+
next,
|
|
252
|
+
all: this.states,
|
|
253
|
+
params,
|
|
254
|
+
match,
|
|
255
|
+
move: this.currentTempData.move,
|
|
256
|
+
event: this.currentTempData.event,
|
|
257
|
+
route,
|
|
258
|
+
};
|
|
259
|
+
route.handler(data);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
public on(route: KitRoute) {
|
|
265
|
+
if (
|
|
266
|
+
!this.routes.some(
|
|
267
|
+
(r) => r.key === route.key && r.handler === route.handler
|
|
268
|
+
)
|
|
269
|
+
) {
|
|
270
|
+
this.routes.push(route);
|
|
271
|
+
// Call the handler now so it gets current info
|
|
272
|
+
this.locationChanged([route]);
|
|
273
|
+
} else {
|
|
274
|
+
throw new Error("Route already registered");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return route;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
public off(route: KitRoute) {
|
|
281
|
+
this.routes = this.routes.filter((r) => r !== route);
|
|
282
|
+
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private beforePushState() {
|
|
287
|
+
const index = this.getStateIndex(history.state?.id);
|
|
288
|
+
const currentState = this.getActiveState(index);
|
|
289
|
+
this.setScrollData(currentState);
|
|
290
|
+
// Drop states after the current one (went back, then pushed a new one)
|
|
291
|
+
this.setSessionData({
|
|
292
|
+
states: this.states.slice(0, index + 1),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private setScrollData(state: KitRouteState) {
|
|
297
|
+
if (window.scrollY) {
|
|
298
|
+
// Non-zero: keep `scrollY`
|
|
299
|
+
state.scrollY = window.scrollY;
|
|
300
|
+
}
|
|
301
|
+
if (window.scrollX) {
|
|
302
|
+
// Non-zero: keep `scrollX`
|
|
303
|
+
state.scrollX = window.scrollX;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private getStateIndex(id) {
|
|
308
|
+
if (!id) return 0;
|
|
309
|
+
else {
|
|
310
|
+
// Search from the end in case two states share an id
|
|
311
|
+
const foundState = this.states
|
|
312
|
+
.slice()
|
|
313
|
+
.reverse()
|
|
314
|
+
.find((s) => s.id === id);
|
|
315
|
+
const foundIndex = this.states.findIndex((s) => s === foundState);
|
|
316
|
+
return foundIndex > -1 ? foundIndex : 0;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private getPreviousState(activeStateIndex) {
|
|
321
|
+
if (activeStateIndex < 1) return null;
|
|
322
|
+
else return this.states[activeStateIndex - 1];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private getActiveState(activeStateIndex) {
|
|
326
|
+
return this.states[activeStateIndex]!;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private getNextState(activeStateIndex) {
|
|
330
|
+
if (activeStateIndex >= this.states.length - 1) return null;
|
|
331
|
+
else return this.states[activeStateIndex + 1];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private shouldStripSlash(pathname) {
|
|
335
|
+
return (
|
|
336
|
+
pathname !== "/" &&
|
|
337
|
+
pathname?.endsWith?.("/") &&
|
|
338
|
+
// No trailing-slash route matches this path exactly
|
|
339
|
+
!this.routes.find((route) => {
|
|
340
|
+
const { match } = route.match(pathname);
|
|
341
|
+
return (
|
|
342
|
+
match?.[0] &&
|
|
343
|
+
typeof route.key === "string" &&
|
|
344
|
+
!route.key?.endsWith?.("/")
|
|
345
|
+
);
|
|
346
|
+
})
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
private setSessionData({
|
|
350
|
+
states,
|
|
351
|
+
currentStateId,
|
|
352
|
+
currentTempData,
|
|
353
|
+
}: {
|
|
354
|
+
states?: KitRouteState[];
|
|
355
|
+
currentStateId?: string | null;
|
|
356
|
+
currentTempData?: {
|
|
357
|
+
move: null | "push" | "replace" | "back" | "forward";
|
|
358
|
+
event?: {
|
|
359
|
+
hasUAVisualTransition: boolean;
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
}) {
|
|
363
|
+
this.states = (states ?? this.states).slice(-this.MAX_STATES);
|
|
364
|
+
this.currentStateId = currentStateId ?? this.currentStateId;
|
|
365
|
+
this.currentTempData = currentTempData ?? this.currentTempData;
|
|
366
|
+
requestIdleCb(() => {
|
|
367
|
+
// Idle write is enough; no need to block here
|
|
368
|
+
try {
|
|
369
|
+
sessionStorage.setItem(
|
|
370
|
+
"__spa_router_data__",
|
|
371
|
+
JSON.stringify({
|
|
372
|
+
states: this.states,
|
|
373
|
+
currentStateId: this.currentStateId,
|
|
374
|
+
currentTempData: this.currentTempData,
|
|
375
|
+
})
|
|
376
|
+
);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
KitLogger.error("Error setting session data", e);
|
|
379
|
+
}
|
|
380
|
+
}, 0);
|
|
381
|
+
}
|
|
382
|
+
private getInitSessionData() {
|
|
383
|
+
let sessionStorageData;
|
|
384
|
+
try {
|
|
385
|
+
sessionStorageData = JSON.parse(
|
|
386
|
+
sessionStorage.getItem("__spa_router_data__") || "null"
|
|
387
|
+
);
|
|
388
|
+
} catch (e) {
|
|
389
|
+
KitLogger.error("Error getting session data", e);
|
|
390
|
+
}
|
|
391
|
+
return (
|
|
392
|
+
// Restore session data, or start fresh
|
|
393
|
+
sessionStorageData || {
|
|
394
|
+
states: [buildState({ isInit: true })],
|
|
395
|
+
currentStateId: null,
|
|
396
|
+
currentTempData: {
|
|
397
|
+
move: null,
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function buildState(obj: Partial<KitRouteState> = {}): KitRouteState {
|
|
405
|
+
const newState: Partial<KitRouteState> = Object.assign(
|
|
406
|
+
{},
|
|
407
|
+
{ url: obj.url ?? getUrl() },
|
|
408
|
+
obj.title && { title: obj.title },
|
|
409
|
+
obj.scrollY && { scrollY: obj.scrollY },
|
|
410
|
+
obj.scrollX && { scrollX: obj.scrollX },
|
|
411
|
+
obj.isInit && { isInit: obj.isInit },
|
|
412
|
+
obj.ttypes && obj.ttypes?.length > 0 && { ttypes: obj.ttypes }
|
|
413
|
+
);
|
|
414
|
+
newState.id = hashObject(newState);
|
|
415
|
+
return newState as KitRouteState;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function getUrl() {
|
|
419
|
+
return location.pathname + location.search + location.hash;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export const kitRouter = new KitRouter();
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@excom/kit-router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "kit-router library",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24.13.0"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@excom/hash-object": "^0.1.0",
|
|
12
|
+
"@excom/kit-logger": "^0.1.0",
|
|
13
|
+
"@excom/kit-shims": "^0.1.0"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@excom/heft-rig": "^0.1.0"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"url": "excom-dev/nucleus",
|
|
21
|
+
"directory": "packages/kit-router"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/excom-dev/nucleus/tree/main/packages/kit-router/support/docs/README.md",
|
|
24
|
+
"bugs": "https://github.com/excom-dev/nucleus/issues",
|
|
25
|
+
"keywords": [
|
|
26
|
+
"kit-router"
|
|
27
|
+
],
|
|
28
|
+
"excom": {
|
|
29
|
+
"documented": false,
|
|
30
|
+
"packageType": "library"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "node node_modules/@excom/heft-rig/scripts/vite-build.mjs",
|
|
34
|
+
"build:watch": "node node_modules/@excom/heft-rig/scripts/vite-build-watch.mjs",
|
|
35
|
+
"format": "node node_modules/@excom/heft-rig/scripts/format.mjs",
|
|
36
|
+
"test": "node node_modules/@excom/heft-rig/scripts/vitest.mjs",
|
|
37
|
+
"coverage": "node node_modules/@excom/heft-rig/scripts/coverage.mjs"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Caching has been disabled for this project's "apply-exports" command.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { KitRoute, KitRouter, kitRouter } from "../../index";
|
|
2
|
+
import { KitLogger } from "@excom/kit-logger";
|
|
3
|
+
import {
|
|
4
|
+
afterEach,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
it,
|
|
9
|
+
vi,
|
|
10
|
+
wait,
|
|
11
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
12
|
+
|
|
13
|
+
const SESSION_KEY = "__spa_router_data__";
|
|
14
|
+
|
|
15
|
+
const popstate = (id: string | null, hasUAVisualTransition?: boolean) => {
|
|
16
|
+
if (id !== null) {
|
|
17
|
+
// happy-dom keeps the pushed state object; mutate the id to emulate
|
|
18
|
+
// the browser moving through history.
|
|
19
|
+
(history.state as { id: string }).id = id;
|
|
20
|
+
}
|
|
21
|
+
const e = new PopStateEvent("popstate");
|
|
22
|
+
if (hasUAVisualTransition !== undefined) {
|
|
23
|
+
Object.defineProperty(e, "hasUAVisualTransition", {
|
|
24
|
+
value: hasUAVisualTransition,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
window.dispatchEvent(e);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const statesOf = (router: KitRouter) =>
|
|
31
|
+
(router as unknown as { states: Array<{ id: string; url: string }> }).states;
|
|
32
|
+
|
|
33
|
+
describe("KitRoute params", () => {
|
|
34
|
+
it("returns empty params when a param cannot be decoded", () => {
|
|
35
|
+
const route = new KitRoute("/u/:name", () => {});
|
|
36
|
+
// malformed percent-encoding makes decodeURIComponent throw
|
|
37
|
+
const result = route.match("/u/%E0%A4%A");
|
|
38
|
+
expect(result.match).not.toBeNull();
|
|
39
|
+
expect(result.params).toEqual({});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("supports wildcards", () => {
|
|
43
|
+
const route = new KitRoute("/files/*", () => {});
|
|
44
|
+
expect(route.match("/files/a/b/c.txt").match).not.toBeNull();
|
|
45
|
+
expect(route.match("/files/").match).not.toBeNull();
|
|
46
|
+
expect(route.match("/other").match).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not match nested paths without a trailing segment", () => {
|
|
50
|
+
const route = new KitRoute("/settings", () => {}, { matchNested: true });
|
|
51
|
+
expect(route.match("/settingsx").match).toBeNull();
|
|
52
|
+
expect(route.match("/settings/").match).not.toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("KitRouter history", () => {
|
|
57
|
+
let router: KitRouter;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
history.replaceState(null, "", "/");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
router?.destroy();
|
|
65
|
+
vi.restoreAllMocks();
|
|
66
|
+
sessionStorage.removeItem(SESSION_KEY);
|
|
67
|
+
window.scrollTo(0, 0);
|
|
68
|
+
history.replaceState(null, "", "/");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("exports a singleton router", () => {
|
|
72
|
+
expect(kitRouter).toBeInstanceOf(KitRouter);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("handles popstate back / forward / same-entry moves", () => {
|
|
76
|
+
router = new KitRouter();
|
|
77
|
+
const handler = vi.fn();
|
|
78
|
+
router.on(new KitRoute(/.*/, handler));
|
|
79
|
+
router.pushState({ url: "/p1" });
|
|
80
|
+
router.pushState({ url: "/p2" });
|
|
81
|
+
const states = statesOf(router);
|
|
82
|
+
expect(states.map((s) => s.url)).toEqual(["/", "/p1", "/p2"]);
|
|
83
|
+
handler.mockClear();
|
|
84
|
+
|
|
85
|
+
// back to /p1
|
|
86
|
+
popstate(states[1].id, false);
|
|
87
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(handler).toHaveBeenLastCalledWith(
|
|
89
|
+
expect.objectContaining({
|
|
90
|
+
move: "back",
|
|
91
|
+
event: { hasUAVisualTransition: false },
|
|
92
|
+
previous: expect.objectContaining({ url: "/" }),
|
|
93
|
+
active: expect.objectContaining({ url: "/p1" }),
|
|
94
|
+
next: expect.objectContaining({ url: "/p2" }),
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
expect(router.canGoForward()).toBe(true);
|
|
98
|
+
expect(router.canGoBack()).toBe(true);
|
|
99
|
+
|
|
100
|
+
// forward to /p2
|
|
101
|
+
popstate(states[2].id, true);
|
|
102
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
103
|
+
expect(handler).toHaveBeenLastCalledWith(
|
|
104
|
+
expect.objectContaining({
|
|
105
|
+
move: "forward",
|
|
106
|
+
event: { hasUAVisualTransition: true },
|
|
107
|
+
active: expect.objectContaining({ url: "/p2" }),
|
|
108
|
+
next: null,
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
expect(router.canGoForward()).toBe(false);
|
|
112
|
+
|
|
113
|
+
// popstate on the same entry keeps the last move
|
|
114
|
+
popstate(states[2].id);
|
|
115
|
+
expect(handler).toHaveBeenCalledTimes(3);
|
|
116
|
+
expect(handler).toHaveBeenLastCalledWith(
|
|
117
|
+
expect.objectContaining({ move: "forward" })
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("treats popstate as back when no state id has been tracked yet", () => {
|
|
122
|
+
router = new KitRouter();
|
|
123
|
+
const handler = vi.fn();
|
|
124
|
+
router.on(new KitRoute(/.*/, handler));
|
|
125
|
+
handler.mockClear();
|
|
126
|
+
|
|
127
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
128
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(handler).toHaveBeenLastCalledWith(
|
|
130
|
+
expect.objectContaining({
|
|
131
|
+
move: "back",
|
|
132
|
+
event: { hasUAVisualTransition: false },
|
|
133
|
+
previous: null,
|
|
134
|
+
next: null,
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("stores the scroll position of the outgoing state on push and on popstate", () => {
|
|
140
|
+
router = new KitRouter();
|
|
141
|
+
const handler = vi.fn();
|
|
142
|
+
router.on(new KitRoute(/.*/, handler));
|
|
143
|
+
router.pushState({ url: "/first" });
|
|
144
|
+
handler.mockClear();
|
|
145
|
+
|
|
146
|
+
window.scrollTo(10, 100);
|
|
147
|
+
router.pushState({ url: "/second" });
|
|
148
|
+
expect(handler).toHaveBeenLastCalledWith(
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
previous: expect.objectContaining({
|
|
151
|
+
url: "/first",
|
|
152
|
+
scrollX: 10,
|
|
153
|
+
scrollY: 100,
|
|
154
|
+
}),
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
window.scrollTo(3, 7);
|
|
159
|
+
const states = statesOf(router);
|
|
160
|
+
popstate(states[1].id);
|
|
161
|
+
expect(states[2]).toEqual(
|
|
162
|
+
expect.objectContaining({ url: "/second", scrollX: 3, scrollY: 7 })
|
|
163
|
+
);
|
|
164
|
+
window.scrollTo(0, 0);
|
|
165
|
+
router.pushState({ url: "/third" });
|
|
166
|
+
// zero offsets are not stored
|
|
167
|
+
expect(states[1]).toEqual(
|
|
168
|
+
expect.objectContaining({ scrollX: 10, scrollY: 100 })
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("drops forward states when pushing after going back", () => {
|
|
173
|
+
router = new KitRouter();
|
|
174
|
+
router.on(new KitRoute(/.*/, () => {}));
|
|
175
|
+
router.pushState({ url: "/a" });
|
|
176
|
+
router.pushState({ url: "/b" });
|
|
177
|
+
popstate(statesOf(router)[1].id);
|
|
178
|
+
router.pushState({ url: "/c" });
|
|
179
|
+
expect(statesOf(router).map((s) => s.url)).toEqual(["/", "/a", "/c"]);
|
|
180
|
+
expect(router.canGoForward()).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("keeps state metadata (title, scroll, transition types)", () => {
|
|
184
|
+
router = new KitRouter();
|
|
185
|
+
const handler = vi.fn();
|
|
186
|
+
router.on(new KitRoute(/.*/, handler));
|
|
187
|
+
router.pushState({
|
|
188
|
+
url: "/meta",
|
|
189
|
+
title: "Meta",
|
|
190
|
+
scrollX: 3,
|
|
191
|
+
scrollY: 7,
|
|
192
|
+
ttypes: ["fade"],
|
|
193
|
+
});
|
|
194
|
+
expect(handler).toHaveBeenLastCalledWith(
|
|
195
|
+
expect.objectContaining({
|
|
196
|
+
active: expect.objectContaining({
|
|
197
|
+
url: "/meta",
|
|
198
|
+
title: "Meta",
|
|
199
|
+
scrollX: 3,
|
|
200
|
+
scrollY: 7,
|
|
201
|
+
ttypes: ["fade"],
|
|
202
|
+
}),
|
|
203
|
+
})
|
|
204
|
+
);
|
|
205
|
+
router.replaceState({ url: "/meta-2", ttypes: [] });
|
|
206
|
+
const active = handler.mock.lastCall![0].active;
|
|
207
|
+
expect(active.url).toBe("/meta-2");
|
|
208
|
+
expect(active).not.toHaveProperty("ttypes");
|
|
209
|
+
expect(active).not.toHaveProperty("title");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("canGoBack is true after a push and when the oldest state is not initial", () => {
|
|
213
|
+
router = new KitRouter();
|
|
214
|
+
router.on(new KitRoute(/.*/, () => {}));
|
|
215
|
+
expect(router.canGoBack()).toBe(false);
|
|
216
|
+
router.pushState({ url: "/x" });
|
|
217
|
+
expect(router.canGoBack()).toBe(true);
|
|
218
|
+
|
|
219
|
+
// exceeding MAX_STATES drops the initial state; back is still possible
|
|
220
|
+
router.MAX_STATES = 1;
|
|
221
|
+
router.pushState({ url: "/y" });
|
|
222
|
+
expect(statesOf(router).map((s) => s.url)).toEqual(["/y"]);
|
|
223
|
+
expect(router.canGoBack()).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("back / forward delegate to history.go", () => {
|
|
227
|
+
router = new KitRouter();
|
|
228
|
+
const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
|
|
229
|
+
router.back();
|
|
230
|
+
expect(goSpy).toHaveBeenLastCalledWith(-1);
|
|
231
|
+
router.back(-2);
|
|
232
|
+
expect(goSpy).toHaveBeenLastCalledWith(-2);
|
|
233
|
+
router.forward();
|
|
234
|
+
expect(goSpy).toHaveBeenLastCalledWith(1);
|
|
235
|
+
router.forward(3);
|
|
236
|
+
expect(goSpy).toHaveBeenLastCalledWith(3);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("trailing slash", () => {
|
|
240
|
+
it("strips a trailing slash when no route wants it", () => {
|
|
241
|
+
router = new KitRouter();
|
|
242
|
+
const handler = vi.fn();
|
|
243
|
+
router.on(new KitRoute(/.*/, handler));
|
|
244
|
+
router.on(new KitRoute("/foo", () => {}));
|
|
245
|
+
handler.mockClear();
|
|
246
|
+
router.pushState({ url: "/foo/?q=1#h" });
|
|
247
|
+
expect(location.pathname).toBe("/foo");
|
|
248
|
+
expect(location.search).toBe("?q=1");
|
|
249
|
+
expect(location.hash).toBe("#h");
|
|
250
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
251
|
+
expect(handler).toHaveBeenLastCalledWith(
|
|
252
|
+
expect.objectContaining({ move: "push" })
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("strips a trailing slash when only a trailing-slash string route matches", () => {
|
|
257
|
+
router = new KitRouter();
|
|
258
|
+
const handler = vi.fn();
|
|
259
|
+
router.on(new KitRoute("/bar/", handler));
|
|
260
|
+
handler.mockClear();
|
|
261
|
+
router.pushState({ url: "/bar/" });
|
|
262
|
+
expect(location.pathname).toBe("/bar");
|
|
263
|
+
expect(handler).toHaveBeenLastCalledWith(
|
|
264
|
+
expect.objectContaining({ match: null })
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("keeps a trailing slash when a nested string route matches it", () => {
|
|
269
|
+
router = new KitRouter();
|
|
270
|
+
const handler = vi.fn();
|
|
271
|
+
router.on(new KitRoute("/baz", handler, { matchNested: true }));
|
|
272
|
+
handler.mockClear();
|
|
273
|
+
router.pushState({ url: "/baz/" });
|
|
274
|
+
expect(location.pathname).toBe("/baz/");
|
|
275
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
276
|
+
expect(handler.mock.lastCall![0].match).not.toBeNull();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("never strips the root path", () => {
|
|
280
|
+
router = new KitRouter();
|
|
281
|
+
const handler = vi.fn();
|
|
282
|
+
router.on(new KitRoute("/", handler));
|
|
283
|
+
handler.mockClear();
|
|
284
|
+
router.pushState({ url: "/" });
|
|
285
|
+
expect(location.pathname).toBe("/");
|
|
286
|
+
expect(handler.mock.lastCall![0].match).not.toBeNull();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("session storage", () => {
|
|
291
|
+
it("persists router data and restores it in a new router", async () => {
|
|
292
|
+
router = new KitRouter();
|
|
293
|
+
router.pushState({ url: "/persisted" });
|
|
294
|
+
await wait(5);
|
|
295
|
+
const stored = JSON.parse(sessionStorage.getItem(SESSION_KEY)!);
|
|
296
|
+
expect(stored.states.map((s) => s.url)).toEqual(["/", "/persisted"]);
|
|
297
|
+
expect(stored.currentTempData.move).toBe("push");
|
|
298
|
+
|
|
299
|
+
const restored = new KitRouter();
|
|
300
|
+
try {
|
|
301
|
+
expect(statesOf(restored).map((s) => s.url)).toEqual([
|
|
302
|
+
"/",
|
|
303
|
+
"/persisted",
|
|
304
|
+
]);
|
|
305
|
+
} finally {
|
|
306
|
+
restored.destroy();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("falls back to a fresh state when stored data is unreadable", () => {
|
|
311
|
+
const errorSpy = vi
|
|
312
|
+
.spyOn(KitLogger, "error")
|
|
313
|
+
.mockImplementation(() => {});
|
|
314
|
+
sessionStorage.setItem(SESSION_KEY, "{not json");
|
|
315
|
+
router = new KitRouter();
|
|
316
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
317
|
+
"Error getting session data",
|
|
318
|
+
expect.anything()
|
|
319
|
+
);
|
|
320
|
+
const states = statesOf(router);
|
|
321
|
+
expect(states).toHaveLength(1);
|
|
322
|
+
expect(states[0]).toEqual(expect.objectContaining({ isInit: true }));
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("logs when session data cannot be written", async () => {
|
|
326
|
+
router = new KitRouter();
|
|
327
|
+
const errorSpy = vi
|
|
328
|
+
.spyOn(KitLogger, "error")
|
|
329
|
+
.mockImplementation(() => {});
|
|
330
|
+
const setItemSpy = vi
|
|
331
|
+
.spyOn(sessionStorage, "setItem")
|
|
332
|
+
.mockImplementation(() => {
|
|
333
|
+
throw new Error("quota");
|
|
334
|
+
});
|
|
335
|
+
router.pushState({ url: "/unwritable" });
|
|
336
|
+
await wait(5);
|
|
337
|
+
setItemSpy.mockRestore();
|
|
338
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
339
|
+
"Error setting session data",
|
|
340
|
+
expect.any(Error)
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { KitRoute, KitRouter } from "../../index";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
} from "@excom/heft-rig/node_modules/vitest";
|
|
9
|
+
|
|
10
|
+
describe("KitRoute", () => {
|
|
11
|
+
it("matches routes with params", () => {
|
|
12
|
+
const route = new KitRoute("/users/:id", () => {});
|
|
13
|
+
const match = route.match("/users/123");
|
|
14
|
+
expect(match?.params).toEqual({ id: "123" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("supports nested matches when enabled", () => {
|
|
18
|
+
const route = new KitRoute("/settings", () => {}, { matchNested: true });
|
|
19
|
+
const match = route.match("/settings/profile");
|
|
20
|
+
expect(match.match).not.toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not match non-nested paths by default", () => {
|
|
24
|
+
const route = new KitRoute("/settings", () => {});
|
|
25
|
+
const result = route.match("/settings/profile");
|
|
26
|
+
expect(result.match).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("matches exact path", () => {
|
|
30
|
+
const route = new KitRoute("/about", () => {});
|
|
31
|
+
const result = route.match("/about");
|
|
32
|
+
expect(result.match).not.toBeNull();
|
|
33
|
+
expect(result.params).toEqual({});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("extracts multiple params", () => {
|
|
37
|
+
const route = new KitRoute("/users/:userId/posts/:postId", () => {});
|
|
38
|
+
const result = route.match("/users/42/posts/99");
|
|
39
|
+
expect(result.params).toEqual({ userId: "42", postId: "99" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("decodes URI-encoded params", () => {
|
|
43
|
+
const route = new KitRoute("/search/:query", () => {});
|
|
44
|
+
const result = route.match("/search/hello%20world");
|
|
45
|
+
expect(result.params).toEqual({ query: "hello world" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns null params for non-matching route", () => {
|
|
49
|
+
const route = new KitRoute("/users/:id", () => {});
|
|
50
|
+
const result = route.match("/posts/123");
|
|
51
|
+
expect(result.match).toBeNull();
|
|
52
|
+
expect(result.params).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("works with regex keys", () => {
|
|
56
|
+
const route = new KitRoute(/^\/api\/(.+)$/, () => {});
|
|
57
|
+
const result = route.match("/api/v1/users");
|
|
58
|
+
expect(result.match).not.toBeNull();
|
|
59
|
+
expect(result.match![1]).toBe("v1/users");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("throws for invalid route or handler", () => {
|
|
63
|
+
expect(() => new KitRoute("", () => {})).toThrow("Invalid route");
|
|
64
|
+
expect(() => new KitRoute("/path", null as any)).toThrow(
|
|
65
|
+
"Invalid route",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("throws for invalid key type", () => {
|
|
70
|
+
expect(() => new KitRoute(123 as any, () => {})).toThrow(
|
|
71
|
+
"Invalid route key type",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("matches root path", () => {
|
|
76
|
+
const route = new KitRoute("/", () => {});
|
|
77
|
+
const result = route.match("/");
|
|
78
|
+
expect(result.match).not.toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("KitRouter", () => {
|
|
83
|
+
let router: KitRouter;
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
router?.destroy();
|
|
87
|
+
vi.restoreAllMocks();
|
|
88
|
+
sessionStorage.removeItem("__spa_router_data__");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("registers a route and immediately calls its handler", () => {
|
|
92
|
+
router = new KitRouter();
|
|
93
|
+
const handler = vi.fn();
|
|
94
|
+
const route = new KitRoute(/.*/, handler);
|
|
95
|
+
router.on(route);
|
|
96
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(handler).toHaveBeenCalledWith(
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
active: expect.any(Object),
|
|
100
|
+
match: expect.any(Array),
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("throws when registering a duplicate route", () => {
|
|
106
|
+
router = new KitRouter();
|
|
107
|
+
const handler = vi.fn();
|
|
108
|
+
const route = new KitRoute(/.*/, handler);
|
|
109
|
+
router.on(route);
|
|
110
|
+
expect(() => router.on(route)).toThrow("Route already registered");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("unregisters a route with off()", () => {
|
|
114
|
+
router = new KitRouter();
|
|
115
|
+
const handler = vi.fn();
|
|
116
|
+
const route = new KitRoute(/.*/, handler);
|
|
117
|
+
router.on(route);
|
|
118
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
119
|
+
router.off(route);
|
|
120
|
+
router.pushState({ url: "/test-off" });
|
|
121
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("pushState calls matching handlers", () => {
|
|
125
|
+
router = new KitRouter();
|
|
126
|
+
const handler = vi.fn();
|
|
127
|
+
const route = new KitRoute(/.*/, handler);
|
|
128
|
+
router.on(route);
|
|
129
|
+
handler.mockClear();
|
|
130
|
+
|
|
131
|
+
router.pushState({ url: "/new-page" });
|
|
132
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(handler).toHaveBeenCalledWith(
|
|
134
|
+
expect.objectContaining({ move: "push" }),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("replaceState calls matching handlers", () => {
|
|
139
|
+
router = new KitRouter();
|
|
140
|
+
const handler = vi.fn();
|
|
141
|
+
const route = new KitRoute(/.*/, handler);
|
|
142
|
+
router.on(route);
|
|
143
|
+
handler.mockClear();
|
|
144
|
+
|
|
145
|
+
router.replaceState({ url: "/replaced" });
|
|
146
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(handler).toHaveBeenCalledWith(
|
|
148
|
+
expect.objectContaining({ move: "replace" }),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("canGoBack returns false on initial state", () => {
|
|
153
|
+
router = new KitRouter();
|
|
154
|
+
expect(router.canGoBack()).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("canGoForward returns false with no forward states", () => {
|
|
158
|
+
router = new KitRouter();
|
|
159
|
+
expect(router.canGoForward()).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("provides previous/active/next state to handlers", () => {
|
|
163
|
+
router = new KitRouter();
|
|
164
|
+
const handler = vi.fn();
|
|
165
|
+
const route = new KitRoute(/.*/, handler);
|
|
166
|
+
router.on(route);
|
|
167
|
+
handler.mockClear();
|
|
168
|
+
|
|
169
|
+
router.pushState({ url: "/page1" });
|
|
170
|
+
const data = handler.mock.calls[0][0];
|
|
171
|
+
expect(data.previous).not.toBeNull();
|
|
172
|
+
expect(data.active).toBeDefined();
|
|
173
|
+
expect(data.active.url).toBe("/page1");
|
|
174
|
+
expect(data.next).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("passes route params to handler", () => {
|
|
178
|
+
router = new KitRouter();
|
|
179
|
+
const handler = vi.fn();
|
|
180
|
+
const route = new KitRoute("/items/:id", handler);
|
|
181
|
+
router.on(route);
|
|
182
|
+
handler.mockClear();
|
|
183
|
+
|
|
184
|
+
history.replaceState({}, "", "/items/42");
|
|
185
|
+
router.pushState({ url: "/items/42" });
|
|
186
|
+
const data = handler.mock.calls[0][0];
|
|
187
|
+
expect(data.params).toEqual({ id: "42" });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("stores title in state when provided", () => {
|
|
191
|
+
router = new KitRouter();
|
|
192
|
+
const handler = vi.fn();
|
|
193
|
+
router.on(new KitRoute(/.*/, handler));
|
|
194
|
+
handler.mockClear();
|
|
195
|
+
|
|
196
|
+
router.pushState({ url: "/titled", title: "My Page" });
|
|
197
|
+
const data = handler.mock.calls[0][0];
|
|
198
|
+
expect(data.active.title).toBe("My Page");
|
|
199
|
+
});
|
|
200
|
+
});
|