navgo 3.0.5 → 3.0.6
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/index.d.ts +5 -0
- package/index.js +136 -30
- package/package.json +3 -2
- package/readme.md +35 -0
package/index.d.ts
CHANGED
|
@@ -79,6 +79,11 @@ export interface Options {
|
|
|
79
79
|
before_navigate?(_nav: Navigation): void
|
|
80
80
|
/** Global hook fired after routing completes (data loaded, URL updated, handlers run). */
|
|
81
81
|
after_navigate?(_nav: Navigation): void
|
|
82
|
+
/** Optional hook awaited after `after_navigate` and before scroll handling.
|
|
83
|
+
* Useful for UI frameworks (e.g., Svelte) to flush DOM updates so anchor/top
|
|
84
|
+
* scrolling lands on the correct elements.
|
|
85
|
+
*/
|
|
86
|
+
tick?: () => void | Promise<void>
|
|
82
87
|
/** Global hook fired whenever the URL changes.
|
|
83
88
|
* Triggers for shallow pushes/replaces, hash changes, popstate-shallow, 404s, and full navigations.
|
|
84
89
|
* Receives the router's current snapshot (eg `{ url: URL, route: RouteTuple|null, params: Params }`).
|
package/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { parse } from 'regexparam'
|
|
|
3
3
|
const ℹ = (...args) => {}
|
|
4
4
|
|
|
5
5
|
export default class Navgo {
|
|
6
|
+
/** @type {Options} */
|
|
6
7
|
#opts = {
|
|
7
8
|
base: '/',
|
|
8
9
|
preload_delay: 20,
|
|
@@ -10,25 +11,36 @@ export default class Navgo {
|
|
|
10
11
|
before_navigate: undefined,
|
|
11
12
|
after_navigate: undefined,
|
|
12
13
|
url_changed: undefined,
|
|
14
|
+
tick: undefined,
|
|
13
15
|
}
|
|
16
|
+
/** @type {Array<{ pattern: RegExp, keys: string[]|null, data: RouteTuple }>} */
|
|
14
17
|
#routes = []
|
|
18
|
+
/** @type {string} */
|
|
15
19
|
#base = '/'
|
|
20
|
+
/** @type {RegExp} */
|
|
16
21
|
#base_rgx = /^\/+/
|
|
17
|
-
|
|
22
|
+
/** @type {Map<string, { promise?: Promise<any>, data?: any }>} */
|
|
18
23
|
#preloads = new Map()
|
|
19
|
-
|
|
24
|
+
/** @type {{ url: URL|null, route: RouteTuple|null, params: Params }} */
|
|
20
25
|
#current = { url: null, route: null, params: {} }
|
|
26
|
+
/** @type {number} */
|
|
21
27
|
#route_idx = 0
|
|
22
|
-
|
|
28
|
+
/** @type {boolean} */
|
|
23
29
|
#hash_navigating = false
|
|
30
|
+
/** @type {Map<number, Map<string, { x: number, y: number }>>} */
|
|
31
|
+
#areas_pos = new Map()
|
|
24
32
|
// Latest-wins nav guard: monotonic id and currently active id
|
|
33
|
+
/** @type {number} */
|
|
25
34
|
#nav_seq = 0
|
|
35
|
+
/** @type {number} */
|
|
26
36
|
#nav_active = 0
|
|
37
|
+
/** @type {(e: Event) => void | null} */
|
|
38
|
+
#scroll_handler = null
|
|
27
39
|
|
|
28
40
|
//
|
|
29
41
|
// Event listeners
|
|
30
42
|
//
|
|
31
|
-
#click = e => {
|
|
43
|
+
#click = async e => {
|
|
32
44
|
ℹ('[🧭 event:click]', { type: e?.type, target: e?.target })
|
|
33
45
|
const info = this.#link_from_event(e, true)
|
|
34
46
|
if (!info) return
|
|
@@ -53,12 +65,23 @@ export default class Navgo {
|
|
|
53
65
|
|
|
54
66
|
// different hash on same path — let browser update URL + scroll
|
|
55
67
|
this.#hash_navigating = true
|
|
56
|
-
this.#save_scroll()
|
|
57
68
|
ℹ('[🧭 hash]', 'navigate', { href: url.href })
|
|
58
69
|
return
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
e.preventDefault()
|
|
73
|
+
|
|
74
|
+
// allow the browser to repaint before navigating —
|
|
75
|
+
// this prevents INP scores being penalised
|
|
76
|
+
await new Promise(fulfil => {
|
|
77
|
+
requestAnimationFrame(() => {
|
|
78
|
+
setTimeout(fulfil, 0)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// fallback for edge case where rAF doesn't fire because e.g. tab was backgrounded
|
|
82
|
+
setTimeout(fulfil, 100)
|
|
83
|
+
})
|
|
84
|
+
|
|
62
85
|
ℹ('[🧭 link]', 'intercept', { href: info.href })
|
|
63
86
|
this.goto(info.href, { replace: false }, 'link', e)
|
|
64
87
|
}
|
|
@@ -101,12 +124,28 @@ export default class Navgo {
|
|
|
101
124
|
history.replaceState(next_state, '', location.href)
|
|
102
125
|
this.#route_idx = next_idx
|
|
103
126
|
ℹ('[🧭 event:hashchange]', { idx: next_idx, href: location.href })
|
|
127
|
+
} else {
|
|
128
|
+
// hashchange via Back/Forward — restore previous position when hash is removed
|
|
129
|
+
const idx = history.state?.__navgo?.idx
|
|
130
|
+
if (typeof idx === 'number') this.#route_idx = idx
|
|
131
|
+
if (!location.hash) {
|
|
132
|
+
const pos = this.#areas_pos.get(this.#route_idx)?.get?.('window')
|
|
133
|
+
if (pos) {
|
|
134
|
+
scrollTo(pos.x, pos.y)
|
|
135
|
+
ℹ('[🧭 scroll]', 'restore hash-back', { idx: this.#route_idx, ...pos })
|
|
136
|
+
} else {
|
|
137
|
+
// no saved position for previous entry — default to top
|
|
138
|
+
scrollTo(0, 0)
|
|
139
|
+
ℹ('[🧭 scroll]', 'hash-back -> top')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
104
142
|
}
|
|
105
143
|
// update current URL snapshot and notify
|
|
106
144
|
this.#current.url = new URL(location.href)
|
|
107
145
|
this.#opts.url_changed?.(this.#current)
|
|
108
146
|
}
|
|
109
147
|
|
|
148
|
+
/** @type {any} */
|
|
110
149
|
#hover_timer = null
|
|
111
150
|
#maybe_preload = ev => {
|
|
112
151
|
const info = this.#link_from_event(ev, ev.type === 'mousedown')
|
|
@@ -121,6 +160,24 @@ export default class Navgo {
|
|
|
121
160
|
}
|
|
122
161
|
#tap = ev => this.#maybe_preload(ev)
|
|
123
162
|
|
|
163
|
+
#on_scroll = e => {
|
|
164
|
+
// prevent hash from overwriting the previous entry’s saved position.
|
|
165
|
+
if (this.#hash_navigating) return
|
|
166
|
+
|
|
167
|
+
const el = e?.target
|
|
168
|
+
const id =
|
|
169
|
+
!el || el === window || el === document ? 'window' : el?.dataset?.scrollId || el?.id
|
|
170
|
+
if (!id) return
|
|
171
|
+
const pos =
|
|
172
|
+
id === 'window'
|
|
173
|
+
? { x: scrollX || 0, y: scrollY || 0 }
|
|
174
|
+
: { x: el.scrollLeft || 0, y: el.scrollTop || 0 }
|
|
175
|
+
const m = this.#areas_pos.get(this.#route_idx) || new Map()
|
|
176
|
+
m.set(String(id), pos)
|
|
177
|
+
this.#areas_pos.set(this.#route_idx, m)
|
|
178
|
+
ℹ('[🧭 scroll:set]', this.#route_idx, m)
|
|
179
|
+
}
|
|
180
|
+
|
|
124
181
|
#before_unload = ev => {
|
|
125
182
|
// persist scroll for refresh / session restore
|
|
126
183
|
try {
|
|
@@ -178,7 +235,6 @@ export default class Navgo {
|
|
|
178
235
|
!a.target &&
|
|
179
236
|
!a.download &&
|
|
180
237
|
a.host === location.host &&
|
|
181
|
-
href[0] != '#' &&
|
|
182
238
|
this.#base_rgx.test(a.pathname)
|
|
183
239
|
? { a, href }
|
|
184
240
|
: null
|
|
@@ -226,8 +282,6 @@ export default class Navgo {
|
|
|
226
282
|
}
|
|
227
283
|
|
|
228
284
|
/**
|
|
229
|
-
* Programmatic navigation that runs loaders before changing URL.
|
|
230
|
-
* Also used by popstate to unify the flow.
|
|
231
285
|
* @param {string} [url_raw]
|
|
232
286
|
* @param {{ replace?: boolean }} [opts]
|
|
233
287
|
* @param {'goto'|'link'|'popstate'} [nav_type]
|
|
@@ -283,7 +337,6 @@ export default class Navgo {
|
|
|
283
337
|
from: nav.from?.url?.href,
|
|
284
338
|
to: url.href,
|
|
285
339
|
})
|
|
286
|
-
this.#save_scroll()
|
|
287
340
|
const hit = await this.match(path)
|
|
288
341
|
if (nav_id !== this.#nav_active) return
|
|
289
342
|
|
|
@@ -351,6 +404,7 @@ export default class Navgo {
|
|
|
351
404
|
})
|
|
352
405
|
// await so that apply_scroll is after potential async work
|
|
353
406
|
await this.#opts.after_navigate?.(nav)
|
|
407
|
+
|
|
354
408
|
if (nav_id !== this.#nav_active) return
|
|
355
409
|
ℹ('[🧭 navigate]', hit ? 'done' : 'done (404)', {
|
|
356
410
|
from: nav.from?.url?.href,
|
|
@@ -358,18 +412,33 @@ export default class Navgo {
|
|
|
358
412
|
type: nav.type,
|
|
359
413
|
idx: this.#route_idx,
|
|
360
414
|
})
|
|
415
|
+
|
|
416
|
+
// allow frameworks to flush DOM before scrolling
|
|
417
|
+
await this.#opts.tick?.()
|
|
418
|
+
|
|
361
419
|
this.#apply_scroll(nav)
|
|
362
420
|
this.#opts.url_changed?.(this.#current)
|
|
363
421
|
}
|
|
364
422
|
|
|
365
423
|
/**
|
|
366
424
|
* Shallow push — updates the URL/state but DOES NOT call handlers or loaders.
|
|
367
|
-
* URL changes, content stays put until a real nav.
|
|
368
425
|
*/
|
|
369
426
|
#commit_shallow(url, state, replace) {
|
|
370
427
|
const u = new URL(url || location.href, location.href)
|
|
371
|
-
//
|
|
372
|
-
|
|
428
|
+
// persist current entry's scroll into its state so Back after refresh restores it
|
|
429
|
+
const prev = history.state && typeof history.state == 'object' ? history.state : {}
|
|
430
|
+
history.replaceState(
|
|
431
|
+
{ ...prev, __navgo: { ...prev.__navgo, pos: { x: scrollX || 0, y: scrollY || 0 } } },
|
|
432
|
+
'',
|
|
433
|
+
location.href,
|
|
434
|
+
)
|
|
435
|
+
// also stash per-URL scroll in session storage as a fallback across reloads
|
|
436
|
+
try {
|
|
437
|
+
sessionStorage.setItem(
|
|
438
|
+
`__navgo_scroll:${location.href}`,
|
|
439
|
+
JSON.stringify({ x: scrollX || 0, y: scrollY || 0 }),
|
|
440
|
+
)
|
|
441
|
+
} catch {}
|
|
373
442
|
const idx = this.#route_idx + (replace ? 0 : 1)
|
|
374
443
|
const st = { ...state, __navgo: { ...state?.__navgo, shallow: true, idx } }
|
|
375
444
|
history[(replace ? 'replace' : 'push') + 'State'](st, '', u.href)
|
|
@@ -379,8 +448,10 @@ export default class Navgo {
|
|
|
379
448
|
})
|
|
380
449
|
// Popstate handler checks state.__navgo.shallow and skips router processing
|
|
381
450
|
this.#route_idx = idx
|
|
382
|
-
// carry forward current
|
|
383
|
-
this.#
|
|
451
|
+
// carry forward current window position for the shallow entry so Forward restores correctly
|
|
452
|
+
const m = this.#areas_pos.get(idx) || new Map()
|
|
453
|
+
m.set('window', { x: scrollX || 0, y: scrollY || 0 })
|
|
454
|
+
this.#areas_pos.set(idx, m)
|
|
384
455
|
if (!replace) this.#clear_onward_history()
|
|
385
456
|
// update current URL snapshot and notify
|
|
386
457
|
this.#current.url = u
|
|
@@ -511,6 +582,8 @@ export default class Navgo {
|
|
|
511
582
|
addEventListener('click', this.#click)
|
|
512
583
|
addEventListener('beforeunload', this.#before_unload)
|
|
513
584
|
addEventListener('hashchange', this.#on_hashchange)
|
|
585
|
+
this.#scroll_handler = throttle(this.#on_scroll, 100)
|
|
586
|
+
addEventListener('scroll', this.#scroll_handler, { capture: true })
|
|
514
587
|
|
|
515
588
|
if (this.#opts.preload_on_hover) {
|
|
516
589
|
// @ts-expect-error
|
|
@@ -545,18 +618,12 @@ export default class Navgo {
|
|
|
545
618
|
removeEventListener('mousedown', this.#tap)
|
|
546
619
|
removeEventListener('beforeunload', this.#before_unload)
|
|
547
620
|
removeEventListener('hashchange', this.#on_hashchange)
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
//
|
|
551
|
-
// Scroll
|
|
552
|
-
//
|
|
553
|
-
#save_scroll() {
|
|
554
|
-
this.#scroll.set(this.#route_idx, { x: scrollX, y: scrollY })
|
|
555
|
-
ℹ('[🧭 scroll]', 'save', { idx: this.#route_idx, x: scrollX, y: scrollY })
|
|
621
|
+
removeEventListener('scroll', this.#scroll_handler, { capture: true })
|
|
622
|
+
this.#areas_pos.clear()
|
|
556
623
|
}
|
|
557
624
|
|
|
558
625
|
#clear_onward_history() {
|
|
559
|
-
for (const k of this.#
|
|
626
|
+
for (const k of this.#areas_pos.keys()) if (k > this.#route_idx) this.#areas_pos.delete(k)
|
|
560
627
|
ℹ('[🧭 scroll]', 'clear onward', { upto: this.#route_idx })
|
|
561
628
|
}
|
|
562
629
|
|
|
@@ -579,18 +646,36 @@ export default class Navgo {
|
|
|
579
646
|
// 1) On back/forward, restore saved position if available
|
|
580
647
|
if (t === 'popstate') {
|
|
581
648
|
const ev_state = ctx?.state ?? ctx?.event?.state
|
|
582
|
-
const
|
|
649
|
+
const st = ev_state?.__navgo
|
|
650
|
+
const idx = st?.idx
|
|
583
651
|
const target_idx = typeof idx === 'number' ? idx : this.#route_idx - 1
|
|
584
652
|
this.#route_idx = target_idx
|
|
585
|
-
const
|
|
653
|
+
const m = this.#areas_pos.get(target_idx)
|
|
654
|
+
let pos = st?.pos || m?.get?.('window')
|
|
655
|
+
if (!pos) {
|
|
656
|
+
try {
|
|
657
|
+
const k = `__navgo_scroll:${location.href}`
|
|
658
|
+
pos = JSON.parse(sessionStorage.getItem(k)) || null
|
|
659
|
+
sessionStorage.removeItem(k)
|
|
660
|
+
} catch {}
|
|
661
|
+
}
|
|
586
662
|
if (pos) {
|
|
587
663
|
scrollTo(pos.x, pos.y)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
664
|
+
// re-apply on next tick to resist late reflows (e.g. images)
|
|
665
|
+
setTimeout(() => scrollTo(pos.x, pos.y), 0)
|
|
666
|
+
ℹ('[🧭 scroll]', 'restore popstate', { idx: target_idx, ...pos })
|
|
667
|
+
}
|
|
668
|
+
for (const [id, p] of m || []) {
|
|
669
|
+
if (id === 'window') continue
|
|
670
|
+
const sel = `[data-scroll-id="${CSS.escape(id)}"],` + `#${CSS.escape(id)}`
|
|
671
|
+
const el = document.querySelector(sel)
|
|
672
|
+
if (el) {
|
|
673
|
+
el.scrollTo?.(p.x, p.y)
|
|
674
|
+
el.scrollLeft = p.x
|
|
675
|
+
el.scrollTop = p.y
|
|
676
|
+
}
|
|
593
677
|
}
|
|
678
|
+
if (pos || m) return
|
|
594
679
|
}
|
|
595
680
|
// 2) If there is a hash, prefer anchor scroll
|
|
596
681
|
if (hash && this.#scroll_to_hash(hash)) {
|
|
@@ -635,6 +720,27 @@ export default class Navgo {
|
|
|
635
720
|
}
|
|
636
721
|
}
|
|
637
722
|
|
|
723
|
+
function throttle(fn, ms) {
|
|
724
|
+
let t,
|
|
725
|
+
last = 0
|
|
726
|
+
return e => {
|
|
727
|
+
const now = Date.now()
|
|
728
|
+
if (now - last >= ms) {
|
|
729
|
+
last = now
|
|
730
|
+
fn(e)
|
|
731
|
+
} else {
|
|
732
|
+
clearTimeout(t)
|
|
733
|
+
t = setTimeout(
|
|
734
|
+
() => {
|
|
735
|
+
last = Date.now()
|
|
736
|
+
fn(e)
|
|
737
|
+
},
|
|
738
|
+
ms - (now - last),
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
638
744
|
/** @typedef {import('./index.d.ts').Options} Options */
|
|
639
745
|
/** @typedef {import('./index.d.ts').RouteTuple} RouteTuple */
|
|
640
746
|
/** @typedef {import('./index.d.ts').MatchResult} MatchResult */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "navgo",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.6",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/mustafa0x/navgo.git"
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "perl -0777 -i -pe 's/console.debug\\(...args\\)/{}/g' index.js",
|
|
19
|
-
"prepublishOnly": "pnpm run build",
|
|
19
|
+
"prepublishOnly": "pnpm test && pnpm run build",
|
|
20
20
|
"test": "vitest run index.test.js",
|
|
21
21
|
"test:e2e": "playwright test",
|
|
22
|
+
"start:testsite": "pnpm vite dev test/site --port 5714",
|
|
22
23
|
"types": "tsc -p test/types"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
package/readme.md
CHANGED
|
@@ -51,6 +51,9 @@ const router = new Navgo(routes, {
|
|
|
51
51
|
|
|
52
52
|
console.log('after_navigate', nav.to?.url.pathname, nav.to?.data)
|
|
53
53
|
},
|
|
54
|
+
// let your framework flush DOM before scroll
|
|
55
|
+
// e.g. in Svelte: `import { tick } from 'svelte'`
|
|
56
|
+
tick: tick,
|
|
54
57
|
url_changed(cur) {
|
|
55
58
|
// fires on shallow/hash/popstate-shallow/404 and full navigations
|
|
56
59
|
// `cur` is the router snapshot: { url: URL, route, params }
|
|
@@ -97,6 +100,8 @@ Notes:
|
|
|
97
100
|
- App-level hook called once per navigation attempt after the per-route guard and before loaders/URL update. May call `nav.cancel()` synchronously to prevent navigation.
|
|
98
101
|
- `after_navigate`: `(nav: Navigation) => void`
|
|
99
102
|
- App-level hook called after routing completes (URL updated, data loaded). `nav.to.data` holds any loader data.
|
|
103
|
+
- `tick`: `() => void | Promise<void>`
|
|
104
|
+
- Awaited after `after_navigate` and before scroll handling; useful for frameworks to flush DOM so anchor/top scrolling lands correctly.
|
|
100
105
|
- `url_changed`: `(snapshot: any) => void`
|
|
101
106
|
- Fires on every URL change — shallow `push_state`/`replace_state`, hash changes, `popstate` shallow entries, 404s, and full navigations.
|
|
102
107
|
- Receives the router's current snapshot: an object like `{ url: URL, route: RouteTuple|null, params: Params }`.
|
|
@@ -233,6 +238,26 @@ Notes:
|
|
|
233
238
|
|
|
234
239
|
On `beforeunload`, the current scroll position is saved to `sessionStorage` and restored on the next load of the same URL (e.g., refresh or tab restore).
|
|
235
240
|
|
|
241
|
+
### Scroll Restoration (areas)
|
|
242
|
+
|
|
243
|
+
Navgo caches/restores scroll positions for the window and any scrollable element that has a stable identifier:
|
|
244
|
+
|
|
245
|
+
- Give your element either an `id` or `data-scroll-id="..."`.
|
|
246
|
+
- Navgo listens to `scroll` globally (capture) and records positions per history entry.
|
|
247
|
+
- On `popstate`, it restores matching elements before paint.
|
|
248
|
+
|
|
249
|
+
Example:
|
|
250
|
+
|
|
251
|
+
```html
|
|
252
|
+
<div id="pane" class="overflow-auto">...</div>
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Or with a custom id:
|
|
256
|
+
|
|
257
|
+
```html
|
|
258
|
+
<div data-scroll-id="pane">...</div>
|
|
259
|
+
```
|
|
260
|
+
|
|
236
261
|
### preload(uri)
|
|
237
262
|
|
|
238
263
|
Returns: `Promise<unknown | void>`
|
|
@@ -292,6 +317,8 @@ For `link` and `goto` navigations that match a route:
|
|
|
292
317
|
→ cache data by formatted path
|
|
293
318
|
→ history.push/replaceState(new URL)
|
|
294
319
|
→ after_navigate(nav)
|
|
320
|
+
→ tick()? // optional app-provided await before scroll
|
|
321
|
+
→ scroll restore/hash/top
|
|
295
322
|
```
|
|
296
323
|
|
|
297
324
|
- If a loader throws/rejects, navigation continues and `after_navigate(..., with nav.to.data = { __error })` is delivered so UI can render an error state.
|
|
@@ -348,3 +375,11 @@ scroll flow
|
|
|
348
375
|
- `Navgo.validators.one_of(iterable)` — `true` iff the value is in the provided set.
|
|
349
376
|
|
|
350
377
|
Attach validators via a route tuple's `data.param_validators` to constrain matches.
|
|
378
|
+
|
|
379
|
+
# Credits
|
|
380
|
+
|
|
381
|
+
This router integrates ideas and small portions of code from these fantastic projects:
|
|
382
|
+
|
|
383
|
+
- SvelteKit — https://github.com/sveltejs/kit
|
|
384
|
+
- navaid — https://github.com/lukeed/navaid
|
|
385
|
+
- TanStack Router — https://github.com/TanStack/router
|