navgo 3.0.4 → 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.
Files changed (4) hide show
  1. package/index.d.ts +25 -20
  2. package/index.js +152 -45
  3. package/package.json +4 -2
  4. package/readme.md +53 -19
package/index.d.ts CHANGED
@@ -7,23 +7,23 @@ export type Params = Record<string, string | null | undefined>
7
7
 
8
8
  /** Built-in validator helpers shape. */
9
9
  export interface ValidatorHelpers {
10
- int(opts?: {
10
+ int(_opts?: {
11
11
  min?: number | null
12
12
  max?: number | null
13
- }): (value: string | null | undefined) => boolean
14
- oneOf(values: Iterable<string>): (value: string | null | undefined) => boolean
13
+ }): (_value: string | null | undefined) => boolean
14
+ one_of(_values: Iterable<string>): (_value: string | null | undefined) => boolean
15
15
  }
16
16
 
17
17
  /** Optional per-route hooks recognized by Navgo. */
18
18
  export interface Hooks {
19
19
  /** Validate params with custom per-param validators. Return `false` to skip a match. */
20
- param_validators?: Record<string, (value: string | null | undefined) => boolean>
20
+ param_validators?: Record<string, (_value: string | null | undefined) => boolean>
21
21
  /** Load data for a route before navigation. May return a Promise or an array of values/promises. */
22
- loaders?(params: Params): unknown | Promise<unknown> | Array<unknown | Promise<unknown>>
22
+ loaders?(_params: Params): unknown | Promise<unknown> | Array<unknown | Promise<unknown>>
23
23
  /** Predicate used during match(); may be async. If it returns `false`, the route is skipped. */
24
- validate?(params: Params): boolean | Promise<boolean>
24
+ validate?(_params: Params): boolean | Promise<boolean>
25
25
  /** Route-level navigation guard, called on the current route when leaving it. Synchronous only; call `nav.cancel()` to prevent navigation. */
26
- beforeRouteLeave?(nav: Navigation): void
26
+ before_route_leave?(_nav: Navigation): void
27
27
  }
28
28
 
29
29
  export interface NavigationTarget {
@@ -39,7 +39,7 @@ export interface Navigation {
39
39
  type: 'link' | 'goto' | 'popstate' | 'leave'
40
40
  from: NavigationTarget | null
41
41
  to: NavigationTarget | null
42
- willUnload: boolean
42
+ will_unload: boolean
43
43
  cancelled: boolean
44
44
  /** The original browser event that initiated navigation, when available. */
45
45
  event?: Event
@@ -62,7 +62,7 @@ export type Router<T = unknown> = Navgo<T>
62
62
  export interface NavgoHistoryMeta {
63
63
  /** Monotonic index of the current history entry for scroll restoration. */
64
64
  idx: number
65
- /** Present when the entry was created via shallow `pushState`/`replaceState`. */
65
+ /** Present when the entry was created via shallow `push_state`/`replace_state`. */
66
66
  shallow?: boolean
67
67
  /** Origin of the navigation that created this entry. */
68
68
  type?: 'link' | 'goto' | 'popstate'
@@ -75,32 +75,37 @@ export interface Options {
75
75
  preload_delay?: number
76
76
  /** Disable hover/touch preloading when `false`. Default true. */
77
77
  preload_on_hover?: boolean
78
- /** Global hook fired after per-route `beforeRouteLeave`, before loaders/history change. Can cancel. */
79
- before_navigate?(nav: Navigation): void
78
+ /** Global hook fired after per-route `before_route_leave`, before loaders/history change. Can cancel. */
79
+ before_navigate?(_nav: Navigation): void
80
80
  /** Global hook fired after routing completes (data loaded, URL updated, handlers run). */
81
- after_navigate?(nav: Navigation): void
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 }`).
85
90
  */
86
- url_changed?(payload: any): void
91
+ url_changed?(_payload: any): void
87
92
  }
88
93
 
89
94
  /** Navgo default export: class-based router. */
90
95
  export default class Navgo<T = unknown> {
91
- constructor(routes?: Array<RouteTuple<T>>, opts?: Options)
96
+ constructor(_routes?: Array<RouteTuple<T>>, _opts?: Options)
92
97
  /** Format `url` relative to the configured base. */
93
- format(url: string): string | false
98
+ format(_url: string): string | false
94
99
  /** SvelteKit-like navigation that runs loaders before updating the URL. */
95
- goto(url: string, opts?: { replace?: boolean }): Promise<void>
100
+ goto(_url: string, _opts?: { replace?: boolean }): Promise<void>
96
101
  /** Shallow push — updates URL/state without triggering handlers. */
97
- pushState(url?: string | URL, state?: any): void
102
+ push_state(_url?: string | URL, _state?: any): void
98
103
  /** Shallow replace — updates URL/state without triggering handlers. */
99
- replaceState(url?: string | URL, state?: any): void
104
+ replace_state(_url?: string | URL, _state?: any): void
100
105
  /** Manually preload loaders for a URL (deduped). */
101
- preload(url: string): Promise<unknown | void>
106
+ preload(_url: string): Promise<unknown | void>
102
107
  /** Try to match `url`; returns route tuple and params or `null`. Supports async `validate`. */
103
- match(url: string): Promise<MatchResult<T> | null>
108
+ match(_url: string): Promise<MatchResult<T> | null>
104
109
  /** Attach history + click listeners and immediately process current location. */
105
110
  init(): Promise<void>
106
111
  /** Remove listeners installed by `init()`. */
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
- // preload cache: href -> { promise, data, error }
22
+ /** @type {Map<string, { promise?: Promise<any>, data?: any }>} */
18
23
  #preloads = new Map()
19
- // last matched route info
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
- #scroll = new Map()
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 {
@@ -135,12 +192,12 @@ export default class Navgo {
135
192
  const nav = this.#make_nav({
136
193
  type: 'leave',
137
194
  to: null,
138
- willUnload: true,
195
+ will_unload: true,
139
196
  event: ev,
140
197
  })
141
- this.#current.route?.[1]?.beforeRouteLeave?.(nav)
198
+ this.#current.route?.[1]?.before_route_leave?.(nav)
142
199
  if (nav.cancelled) {
143
- ℹ('[🧭 navigate]', 'cancelled by beforeRouteLeave during unload')
200
+ ℹ('[🧭 navigate]', 'cancelled by before_route_leave during unload')
144
201
  ev.preventDefault()
145
202
  ev.returnValue = ''
146
203
  }
@@ -163,7 +220,7 @@ export default class Navgo {
163
220
  #resolve_url_and_path(url_raw) {
164
221
  if (url_raw[0] == '/' && !this.#base_rgx.test(url_raw)) url_raw = this.#base + url_raw
165
222
  const url = new URL(url_raw, location.href)
166
- const path = this.format(url.pathname)?.match(/[^?#]*/)?.[0]
223
+ const path = this.format(url.pathname).match?.(/[^?#]*/)?.[0]
167
224
  ℹ('[🧭 resolve]', { url_in: url_raw, url: url.href, path })
168
225
  return path ? { url, path } : null
169
226
  }
@@ -178,7 +235,7 @@ export default class Navgo {
178
235
  !a.target &&
179
236
  !a.download &&
180
237
  a.host === location.host &&
181
- (href[0] != '/' || this.#base_rgx.test(href))
238
+ this.#base_rgx.test(a.pathname)
182
239
  ? { a, href }
183
240
  : null
184
241
  }
@@ -200,7 +257,7 @@ export default class Navgo {
200
257
  /**
201
258
  * @returns {Navigation}
202
259
  */
203
- #make_nav({ type, from = undefined, to = undefined, willUnload = false, event = undefined }) {
260
+ #make_nav({ type, from = undefined, to = undefined, will_unload = false, event = undefined }) {
204
261
  const from_obj =
205
262
  from !== undefined
206
263
  ? from
@@ -215,7 +272,7 @@ export default class Navgo {
215
272
  type, // 'link' | 'goto' | 'popstate' | 'leave'
216
273
  from: from_obj,
217
274
  to,
218
- willUnload,
275
+ will_unload,
219
276
  cancelled: false,
220
277
  event,
221
278
  cancel() {
@@ -225,8 +282,6 @@ export default class Navgo {
225
282
  }
226
283
 
227
284
  /**
228
- * Programmatic navigation that runs loaders before changing URL.
229
- * Also used by popstate to unify the flow.
230
285
  * @param {string} [url_raw]
231
286
  * @param {{ replace?: boolean }} [opts]
232
287
  * @param {'goto'|'link'|'popstate'} [nav_type]
@@ -253,9 +308,9 @@ export default class Navgo {
253
308
  })
254
309
 
255
310
  //
256
- // beforeRouteLeave
311
+ // before_route_leave
257
312
  //
258
- this.#current.route?.[1]?.beforeRouteLeave?.(nav)
313
+ this.#current.route?.[1]?.before_route_leave?.(nav)
259
314
  if (nav.cancelled) {
260
315
  // use history.go to cancel the nav, and jump back to where we are
261
316
  if (is_popstate) {
@@ -270,7 +325,7 @@ export default class Navgo {
270
325
  }
271
326
  }
272
327
  }
273
- ℹ('[🧭 goto]', 'cancelled by beforeRouteLeave')
328
+ ℹ('[🧭 goto]', 'cancelled by before_route_leave')
274
329
  return
275
330
  }
276
331
 
@@ -282,7 +337,6 @@ export default class Navgo {
282
337
  from: nav.from?.url?.href,
283
338
  to: url.href,
284
339
  })
285
- this.#save_scroll()
286
340
  const hit = await this.match(path)
287
341
  if (nav_id !== this.#nav_active) return
288
342
 
@@ -350,6 +404,7 @@ export default class Navgo {
350
404
  })
351
405
  // await so that apply_scroll is after potential async work
352
406
  await this.#opts.after_navigate?.(nav)
407
+
353
408
  if (nav_id !== this.#nav_active) return
354
409
  ℹ('[🧭 navigate]', hit ? 'done' : 'done (404)', {
355
410
  from: nav.from?.url?.href,
@@ -357,29 +412,46 @@ export default class Navgo {
357
412
  type: nav.type,
358
413
  idx: this.#route_idx,
359
414
  })
415
+
416
+ // allow frameworks to flush DOM before scrolling
417
+ await this.#opts.tick?.()
418
+
360
419
  this.#apply_scroll(nav)
361
420
  this.#opts.url_changed?.(this.#current)
362
421
  }
363
422
 
364
423
  /**
365
424
  * Shallow push — updates the URL/state but DOES NOT call handlers or loaders.
366
- * URL changes, content stays put until a real nav.
367
425
  */
368
426
  #commit_shallow(url, state, replace) {
369
427
  const u = new URL(url || location.href, location.href)
370
- // save scroll for current index before shallow change
371
- this.#save_scroll()
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 {}
372
442
  const idx = this.#route_idx + (replace ? 0 : 1)
373
- const st = { ...state, __navaid: { ...state?.__navaid, shallow: true, idx } }
443
+ const st = { ...state, __navgo: { ...state?.__navgo, shallow: true, idx } }
374
444
  history[(replace ? 'replace' : 'push') + 'State'](st, '', u.href)
375
- ℹ('[🧭 history]', replace ? 'replaceState(shallow)' : 'pushState(shallow)', {
445
+ ℹ('[🧭 history]', replace ? 'replace_state(shallow)' : 'push_state(shallow)', {
376
446
  idx,
377
447
  href: u.href,
378
448
  })
379
- // Popstate handler checks state.__navaid.shallow and skips router processing
449
+ // Popstate handler checks state.__navgo.shallow and skips router processing
380
450
  this.#route_idx = idx
381
- // carry forward current scroll position for the shallow entry so Forward restores correctly
382
- this.#scroll.set(idx, { x: scrollX, y: scrollY })
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)
383
455
  if (!replace) this.#clear_onward_history()
384
456
  // update current URL snapshot and notify
385
457
  this.#current.url = u
@@ -387,11 +459,11 @@ export default class Navgo {
387
459
  }
388
460
 
389
461
  /** @param {string|URL} [url] @param {any} [state] */
390
- pushState(url, state) {
462
+ push_state(url, state) {
391
463
  this.#commit_shallow(url, state, false)
392
464
  }
393
465
  /** @param {string|URL} [url] @param {any} [state] */
394
- replaceState(url, state) {
466
+ replace_state(url, state) {
395
467
  this.#commit_shallow(url, state, true)
396
468
  }
397
469
 
@@ -510,6 +582,8 @@ export default class Navgo {
510
582
  addEventListener('click', this.#click)
511
583
  addEventListener('beforeunload', this.#before_unload)
512
584
  addEventListener('hashchange', this.#on_hashchange)
585
+ this.#scroll_handler = throttle(this.#on_scroll, 100)
586
+ addEventListener('scroll', this.#scroll_handler, { capture: true })
513
587
 
514
588
  if (this.#opts.preload_on_hover) {
515
589
  // @ts-expect-error
@@ -544,18 +618,12 @@ export default class Navgo {
544
618
  removeEventListener('mousedown', this.#tap)
545
619
  removeEventListener('beforeunload', this.#before_unload)
546
620
  removeEventListener('hashchange', this.#on_hashchange)
547
- }
548
-
549
- //
550
- // Scroll
551
- //
552
- #save_scroll() {
553
- this.#scroll.set(this.#route_idx, { x: scrollX, y: scrollY })
554
- ℹ('[🧭 scroll]', 'save', { idx: this.#route_idx, x: scrollX, y: scrollY })
621
+ removeEventListener('scroll', this.#scroll_handler, { capture: true })
622
+ this.#areas_pos.clear()
555
623
  }
556
624
 
557
625
  #clear_onward_history() {
558
- for (const k of this.#scroll.keys()) if (k > this.#route_idx) this.#scroll.delete(k)
626
+ for (const k of this.#areas_pos.keys()) if (k > this.#route_idx) this.#areas_pos.delete(k)
559
627
  ℹ('[🧭 scroll]', 'clear onward', { upto: this.#route_idx })
560
628
  }
561
629
 
@@ -578,18 +646,36 @@ export default class Navgo {
578
646
  // 1) On back/forward, restore saved position if available
579
647
  if (t === 'popstate') {
580
648
  const ev_state = ctx?.state ?? ctx?.event?.state
581
- const idx = ev_state?.__navgo?.idx
649
+ const st = ev_state?.__navgo
650
+ const idx = st?.idx
582
651
  const target_idx = typeof idx === 'number' ? idx : this.#route_idx - 1
583
652
  this.#route_idx = target_idx
584
- const pos = this.#scroll.get(target_idx)
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
+ }
585
662
  if (pos) {
586
663
  scrollTo(pos.x, pos.y)
587
- ℹ('[🧭 scroll]', 'restore popstate', {
588
- idx: target_idx,
589
- ...pos,
590
- })
591
- return
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
+ }
592
677
  }
678
+ if (pos || m) return
593
679
  }
594
680
  // 2) If there is a hash, prefer anchor scroll
595
681
  if (hash && this.#scroll_to_hash(hash)) {
@@ -627,13 +713,34 @@ export default class Navgo {
627
713
  return true
628
714
  }
629
715
  },
630
- oneOf(values) {
716
+ one_of(values) {
631
717
  const set = new Set(values)
632
718
  return v => set.has(v)
633
719
  },
634
720
  }
635
721
  }
636
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
+
637
744
  /** @typedef {import('./index.d.ts').Options} Options */
638
745
  /** @typedef {import('./index.d.ts').RouteTuple} RouteTuple */
639
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.4",
3
+ "version": "3.0.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mustafa0x/navgo.git"
@@ -16,8 +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
+ "test:e2e": "playwright test",
22
+ "start:testsite": "pnpm vite dev test/site --port 5714",
21
23
  "types": "tsc -p test/types"
22
24
  },
23
25
  "files": [
package/readme.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ## Install
2
2
 
3
3
  ```
4
- $ pnpm install --dev navaid
4
+ $ pnpm install --dev navgo
5
5
  ```
6
6
 
7
7
  ## Usage
@@ -26,7 +26,7 @@ const routes = [
26
26
  // load data before URL changes; result goes to after_navigate(...)
27
27
  loaders: params => fetch('/api/admin').then(r => r.json()),
28
28
  // per-route guard; cancel synchronously to block nav
29
- beforeRouteLeave(nav) {
29
+ before_route_leave(nav) {
30
30
  if ((nav.type === 'link' || nav.type === 'nav') && !confirm('Enter admin?')) {
31
31
  nav.cancel()
32
32
  }
@@ -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,8 +100,10 @@ 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
- - Fires on every URL change — shallow `pushState`/`replaceState`, hash changes, `popstate` shallow entries, 404s, and full navigations.
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 }`.
103
108
  - The snapshot type is intentionally `any` and may evolve without a breaking change.
104
109
  - `preload_delay`: `number` (default `20`)
@@ -116,7 +121,7 @@ Important: Navgo only processes routes that match your `base` path.
116
121
  - Run before URL changes on `link`/`nav`. Results are cached per formatted path and forwarded to `after_navigate`.
117
122
  - validate?(params): `boolean | Promise<boolean>`
118
123
  - Predicate called during matching. If it returns or resolves to `false`, the route is skipped.
119
- - beforeRouteLeave?(nav): `(nav: Navigation) => void`
124
+ - before_route_leave?(nav): `(nav: Navigation) => void`
120
125
  - Guard called once per navigation attempt on the current route (leave). Call `nav.cancel()` synchronously to prevent navigation. For `popstate`, cancellation auto-reverts the history jump.
121
126
 
122
127
  The `Navigation` object contains:
@@ -126,7 +131,7 @@ The `Navigation` object contains:
126
131
  type: 'link' | 'nav' | 'popstate' | 'leave',
127
132
  from: { url, params, route } | null,
128
133
  to: { url, params, route } | null,
129
- willUnload: boolean,
134
+ will_unload: boolean,
130
135
  cancelled: boolean,
131
136
  event?: Event,
132
137
  cancel(): void
@@ -152,7 +157,7 @@ const routes = [
152
157
  /* ... */
153
158
  },
154
159
  loaders: params => fetch('/api/admin/stats').then(r => r.json()),
155
- beforeRouteLeave(nav) {
160
+ before_route_leave(nav) {
156
161
  if (nav.type === 'link' || nav.type === 'nav') {
157
162
  if (!confirm('Enter admin area?')) nav.cancel()
158
163
  }
@@ -204,7 +209,7 @@ The desired path to navigate. If it begins with `/` and does not match the confi
204
209
  Type: `Object`
205
210
 
206
211
  - replace: `Boolean` (default `false`)
207
- - When `true`, uses `history.replaceState`; otherwise `history.pushState`.
212
+ - When `true`, uses `history.replaceState`; otherwise `history.pushState`.
208
213
 
209
214
  ### init()
210
215
 
@@ -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>`
@@ -240,13 +265,13 @@ Returns: `Promise<unknown | void>`
240
265
  Preload a route's `loaders` data for a given `uri` without navigating. Concurrent calls for the same path are deduped.
241
266
  Note: Resolves to `undefined` when the matched route has no `loaders`.
242
267
 
243
- ### pushState(url?, state?)
268
+ ### push_state(url?, state?)
244
269
 
245
270
  Returns: `void`
246
271
 
247
272
  Perform a shallow history push: updates the URL/state without triggering route processing.
248
273
 
249
- ### replaceState(url?, state?)
274
+ ### replace_state(url?, state?)
250
275
 
251
276
  Returns: `void`
252
277
 
@@ -266,9 +291,8 @@ This section explains, in detail, how navigation is processed: matching, hooks,
266
291
  - `goto` — programmatic navigation via `router.goto(...)`.
267
292
  - `popstate` — browser back/forward.
268
293
  - `leave` — page is unloading (refresh, external navigation, tab close) via `beforeunload`.
269
- - `pushState` (shallow)?
270
294
 
271
- The router passes the type to your route-level `beforeRouteLeave(nav)` hook.
295
+ The router passes the type to your route-level `before_route_leave(nav)` hook.
272
296
 
273
297
  ### Matching and Params
274
298
 
@@ -286,13 +310,15 @@ For `link` and `goto` navigations that match a route:
286
310
 
287
311
  ```
288
312
  [click <a>] or [router.goto()]
289
- beforeRouteLeave({ type }) // per-route guard
313
+ before_route_leave({ type }) // per-route guard
290
314
  → before_navigate(nav) // app-level start
291
315
  → cancelled? yes → stop
292
316
  → no → run loaders(params) // may be value, Promise, or Promise[]
293
317
  → cache data by formatted path
294
318
  → history.push/replaceState(new URL)
295
319
  → after_navigate(nav)
320
+ → tick()? // optional app-provided await before scroll
321
+ → scroll restore/hash/top
296
322
  ```
297
323
 
298
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.
@@ -300,10 +326,10 @@ For `link` and `goto` navigations that match a route:
300
326
 
301
327
  ### Shallow Routing
302
328
 
303
- Use `pushState(url, state?)` or `replaceState(url, state?)` to update the URL/state without re-running routing logic.
329
+ Use `push_state(url, state?)` or `replace_state(url, state?)` to update the URL/state without re-running routing logic.
304
330
 
305
331
  ```
306
- pushState/replaceState (shallow)
332
+ push_state/replace_state (shallow)
307
333
  → updates history.state and URL
308
334
  → router does not process routing on shallow operations
309
335
  ```
@@ -323,7 +349,7 @@ Navgo manages scroll manually (sets `history.scrollRestoration = 'manual'`) and
323
349
  - If the URL has a `#hash`, scroll to the matching element `id` or `[name="..."]`.
324
350
  - Otherwise, scroll to the top `(0, 0)`.
325
351
  - On `popstate`: restore the saved position for the target history index; if not found but there is a `#hash`, scroll to the anchor instead.
326
- - Shallow `pushState`/`replaceState` never adjust scroll (routing is skipped).
352
+ - Shallow `push_state`/`replace_state` never adjust scroll (routing is skipped).
327
353
 
328
354
  ```
329
355
  scroll flow
@@ -336,16 +362,24 @@ scroll flow
336
362
 
337
363
  - `format(uri)` — normalizes a path relative to `base`. Returns `false` when `uri` is outside of `base`.
338
364
  - `match(uri)` — returns a Promise of `{ route, params } | null` using string/RegExp patterns and validators. Awaits an async `validate(params)` if provided.
339
- - `goto(uri, { replace? })` — fires route-level `beforeRouteLeave('goto')`, calls global `before_navigate`, saves scroll, runs loaders, pushes/replaces, and completes via `after_navigate`.
365
+ - `goto(uri, { replace? })` — fires route-level `before_route_leave('goto')`, calls global `before_navigate`, saves scroll, runs loaders, pushes/replaces, and completes via `after_navigate`.
340
366
  - `init()` — wires global listeners (`popstate`, `pushstate`, `replacestate`, click) and optional hover/tap preloading; immediately processes the current location.
341
367
  - `destroy()` — removes listeners added by `init()`.
342
368
  - `preload(uri)` — pre-executes a route's `loaders` for a path and caches the result; concurrent calls are deduped.
343
- - `pushState(url?, state?)` — shallow push that updates the URL and `history.state` without route processing.
344
- - `replaceState(url?, state?)` — shallow replace that updates the URL and `history.state` without route processing.
369
+ - `push_state(url?, state?)` — shallow push that updates the URL and `history.state` without route processing.
370
+ - `replace_state(url?, state?)` — shallow replace that updates the URL and `history.state` without route processing.
345
371
 
346
372
  ### Built-in Validators
347
373
 
348
374
  - `Navgo.validators.int({ min?, max? })` — `true` iff the value is an integer within optional bounds.
349
- - `Navgo.validators.oneOf(iterable)` — `true` iff the value is in the provided set.
375
+ - `Navgo.validators.one_of(iterable)` — `true` iff the value is in the provided set.
350
376
 
351
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