@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.
@@ -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"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 37.992282000000046
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "../../packages/kit-router": "../../packages/kit-router:I5XuvojLXZI78sLENPismUjh78Y1gFYqYjR+W/4GjSU=:"
3
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
3
+ "rigPackageName": "@excom/heft-rig",
4
+ "rigProfile": "default"
5
+ }
6
+
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
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "@excom/heft-rig/profiles/default/config/tsconfig.json",
3
+ "include": ["./*.ts"],
4
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
5
+ }