navgo 3.0.5 → 3.0.7

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 +15 -0
  2. package/index.js +155 -35
  3. package/package.json +6 -2
  4. package/readme.md +74 -17
package/index.d.ts CHANGED
@@ -75,10 +75,17 @@ 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
+ /** Attach instance to window as `window.navgo`. Default true. */
79
+ attach_to_window?: boolean
78
80
  /** Global hook fired after per-route `before_route_leave`, before loaders/history change. Can cancel. */
79
81
  before_navigate?(_nav: Navigation): void
80
82
  /** Global hook fired after routing completes (data loaded, URL updated, handlers run). */
81
83
  after_navigate?(_nav: Navigation): void
84
+ /** Optional hook awaited after `after_navigate` and before scroll handling.
85
+ * Useful for UI frameworks (e.g., Svelte) to flush DOM updates so anchor/top
86
+ * scrolling lands on the correct elements.
87
+ */
88
+ tick?: () => void | Promise<void>
82
89
  /** Global hook fired whenever the URL changes.
83
90
  * Triggers for shallow pushes/replaces, hash changes, popstate-shallow, 404s, and full navigations.
84
91
  * Receives the router's current snapshot (eg `{ url: URL, route: RouteTuple|null, params: Params }`).
@@ -105,6 +112,14 @@ export default class Navgo<T = unknown> {
105
112
  init(): Promise<void>
106
113
  /** Remove listeners installed by `init()`. */
107
114
  destroy(): void
115
+ /** Writable store with current { url, route, params }. */
116
+ readonly route: import('svelte/store').Writable<{
117
+ url: URL
118
+ route: RouteTuple<T> | null
119
+ params: Params
120
+ }>
121
+ /** Writable store indicating active navigation. */
122
+ readonly is_navigating: import('svelte/store').Writable<boolean>
108
123
  /** Built-in validator helpers (namespaced). */
109
124
  static validators: ValidatorHelpers
110
125
  }
package/index.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { parse } from 'regexparam'
2
+ import { tick } from 'svelte'
3
+ import { writable } from 'svelte/store'
2
4
 
3
5
  const ℹ = (...args) => {}
4
6
 
5
7
  export default class Navgo {
8
+ /** @type {Options} */
6
9
  #opts = {
7
10
  base: '/',
8
11
  preload_delay: 20,
@@ -10,25 +13,39 @@ export default class Navgo {
10
13
  before_navigate: undefined,
11
14
  after_navigate: undefined,
12
15
  url_changed: undefined,
16
+ tick,
17
+ attach_to_window: true,
13
18
  }
19
+ /** @type {Array<{ pattern: RegExp, keys: string[]|null, data: RouteTuple }>} */
14
20
  #routes = []
21
+ /** @type {string} */
15
22
  #base = '/'
23
+ /** @type {RegExp} */
16
24
  #base_rgx = /^\/+/
17
- // preload cache: href -> { promise, data, error }
25
+ /** @type {Map<string, { promise?: Promise<any>, data?: any }>} */
18
26
  #preloads = new Map()
19
- // last matched route info
27
+ /** @type {{ url: URL|null, route: RouteTuple|null, params: Params }} */
20
28
  #current = { url: null, route: null, params: {} }
29
+ /** @type {number} */
21
30
  #route_idx = 0
22
- #scroll = new Map()
31
+ /** @type {boolean} */
23
32
  #hash_navigating = false
33
+ /** @type {Map<number, Map<string, { x: number, y: number }>>} */
34
+ #areas_pos = new Map()
24
35
  // Latest-wins nav guard: monotonic id and currently active id
36
+ /** @type {number} */
25
37
  #nav_seq = 0
38
+ /** @type {number} */
26
39
  #nav_active = 0
40
+ /** @type {(e: Event) => void | null} */
41
+ #scroll_handler = null
42
+ route = writable({ url: new URL(location.href), route: null, params: {} })
43
+ is_navigating = writable(false)
27
44
 
28
45
  //
29
46
  // Event listeners
30
47
  //
31
- #click = e => {
48
+ #click = async e => {
32
49
  ℹ('[🧭 event:click]', { type: e?.type, target: e?.target })
33
50
  const info = this.#link_from_event(e, true)
34
51
  if (!info) return
@@ -53,12 +70,23 @@ export default class Navgo {
53
70
 
54
71
  // different hash on same path — let browser update URL + scroll
55
72
  this.#hash_navigating = true
56
- this.#save_scroll()
57
73
  ℹ('[🧭 hash]', 'navigate', { href: url.href })
58
74
  return
59
75
  }
60
76
 
61
77
  e.preventDefault()
78
+
79
+ // allow the browser to repaint before navigating —
80
+ // this prevents INP scores being penalised
81
+ await new Promise(fulfil => {
82
+ requestAnimationFrame(() => {
83
+ setTimeout(fulfil, 0)
84
+ })
85
+
86
+ // fallback for edge case where rAF doesn't fire because e.g. tab was backgrounded
87
+ setTimeout(fulfil, 100)
88
+ })
89
+
62
90
  ℹ('[🧭 link]', 'intercept', { href: info.href })
63
91
  this.goto(info.href, { replace: false }, 'link', e)
64
92
  }
@@ -76,7 +104,7 @@ export default class Navgo {
76
104
  this.#current.url = target
77
105
  ℹ(' - [🧭 event:popstate]', 'same path+search; skip loaders')
78
106
  this.#apply_scroll(ev)
79
- this.#opts.url_changed?.(this.#current)
107
+ this.route.set(this.#current)
80
108
  return
81
109
  }
82
110
  // Explicit shallow entries (pushState/replaceState) regardless of path
@@ -84,7 +112,7 @@ export default class Navgo {
84
112
  this.#current.url = target
85
113
  ℹ(' - [🧭 event:popstate]', 'shallow entry; skip loaders')
86
114
  this.#apply_scroll(ev)
87
- this.#opts.url_changed?.(this.#current)
115
+ this.route.set(this.#current)
88
116
  return
89
117
  }
90
118
 
@@ -101,12 +129,28 @@ export default class Navgo {
101
129
  history.replaceState(next_state, '', location.href)
102
130
  this.#route_idx = next_idx
103
131
  ℹ('[🧭 event:hashchange]', { idx: next_idx, href: location.href })
132
+ } else {
133
+ // hashchange via Back/Forward — restore previous position when hash is removed
134
+ const idx = history.state?.__navgo?.idx
135
+ if (typeof idx === 'number') this.#route_idx = idx
136
+ if (!location.hash) {
137
+ const pos = this.#areas_pos.get(this.#route_idx)?.get?.('window')
138
+ if (pos) {
139
+ scrollTo(pos.x, pos.y)
140
+ ℹ('[🧭 scroll]', 'restore hash-back', { idx: this.#route_idx, ...pos })
141
+ } else {
142
+ // no saved position for previous entry — default to top
143
+ scrollTo(0, 0)
144
+ ℹ('[🧭 scroll]', 'hash-back -> top')
145
+ }
146
+ }
104
147
  }
105
148
  // update current URL snapshot and notify
106
149
  this.#current.url = new URL(location.href)
107
- this.#opts.url_changed?.(this.#current)
150
+ this.route.set(this.#current)
108
151
  }
109
152
 
153
+ /** @type {any} */
110
154
  #hover_timer = null
111
155
  #maybe_preload = ev => {
112
156
  const info = this.#link_from_event(ev, ev.type === 'mousedown')
@@ -121,6 +165,24 @@ export default class Navgo {
121
165
  }
122
166
  #tap = ev => this.#maybe_preload(ev)
123
167
 
168
+ #on_scroll = e => {
169
+ // prevent hash from overwriting the previous entry’s saved position.
170
+ if (this.#hash_navigating) return
171
+
172
+ const el = e?.target
173
+ const id =
174
+ !el || el === window || el === document ? 'window' : el?.dataset?.scrollId || el?.id
175
+ if (!id) return
176
+ const pos =
177
+ id === 'window'
178
+ ? { x: scrollX || 0, y: scrollY || 0 }
179
+ : { x: el.scrollLeft || 0, y: el.scrollTop || 0 }
180
+ const m = this.#areas_pos.get(this.#route_idx) || new Map()
181
+ m.set(String(id), pos)
182
+ this.#areas_pos.set(this.#route_idx, m)
183
+ ℹ('[🧭 scroll:set]', this.#route_idx, m)
184
+ }
185
+
124
186
  #before_unload = ev => {
125
187
  // persist scroll for refresh / session restore
126
188
  try {
@@ -178,7 +240,6 @@ export default class Navgo {
178
240
  !a.target &&
179
241
  !a.download &&
180
242
  a.host === location.host &&
181
- href[0] != '#' &&
182
243
  this.#base_rgx.test(a.pathname)
183
244
  ? { a, href }
184
245
  : null
@@ -226,8 +287,6 @@ export default class Navgo {
226
287
  }
227
288
 
228
289
  /**
229
- * Programmatic navigation that runs loaders before changing URL.
230
- * Also used by popstate to unify the flow.
231
290
  * @param {string} [url_raw]
232
291
  * @param {{ replace?: boolean }} [opts]
233
292
  * @param {'goto'|'link'|'popstate'} [nav_type]
@@ -242,6 +301,7 @@ export default class Navgo {
242
301
  ℹ('[🧭 goto]', 'invalid url', { url: url_raw })
243
302
  return
244
303
  }
304
+ this.is_navigating.set(true)
245
305
  const { url, path } = info
246
306
 
247
307
  const is_popstate = nav_type === 'popstate'
@@ -271,6 +331,7 @@ export default class Navgo {
271
331
  }
272
332
  }
273
333
  }
334
+ if (nav_id === this.#nav_active) this.is_navigating.set(false)
274
335
  ℹ('[🧭 goto]', 'cancelled by before_route_leave')
275
336
  return
276
337
  }
@@ -283,7 +344,6 @@ export default class Navgo {
283
344
  from: nav.from?.url?.href,
284
345
  to: url.href,
285
346
  })
286
- this.#save_scroll()
287
347
  const hit = await this.match(path)
288
348
  if (nav_id !== this.#nav_active) return
289
349
 
@@ -351,6 +411,7 @@ export default class Navgo {
351
411
  })
352
412
  // await so that apply_scroll is after potential async work
353
413
  await this.#opts.after_navigate?.(nav)
414
+
354
415
  if (nav_id !== this.#nav_active) return
355
416
  ℹ('[🧭 navigate]', hit ? 'done' : 'done (404)', {
356
417
  from: nav.from?.url?.href,
@@ -358,18 +419,34 @@ export default class Navgo {
358
419
  type: nav.type,
359
420
  idx: this.#route_idx,
360
421
  })
422
+
423
+ // allow frameworks to flush DOM before scrolling
424
+ await this.#opts.tick?.()
425
+
361
426
  this.#apply_scroll(nav)
362
- this.#opts.url_changed?.(this.#current)
427
+ this.route.set(this.#current)
428
+ if (nav_id === this.#nav_active) this.is_navigating.set(false)
363
429
  }
364
430
 
365
431
  /**
366
432
  * Shallow push — updates the URL/state but DOES NOT call handlers or loaders.
367
- * URL changes, content stays put until a real nav.
368
433
  */
369
434
  #commit_shallow(url, state, replace) {
370
435
  const u = new URL(url || location.href, location.href)
371
- // save scroll for current index before shallow change
372
- this.#save_scroll()
436
+ // persist current entry's scroll into its state so Back after refresh restores it
437
+ const prev = history.state && typeof history.state == 'object' ? history.state : {}
438
+ history.replaceState(
439
+ { ...prev, __navgo: { ...prev.__navgo, pos: { x: scrollX || 0, y: scrollY || 0 } } },
440
+ '',
441
+ location.href,
442
+ )
443
+ // also stash per-URL scroll in session storage as a fallback across reloads
444
+ try {
445
+ sessionStorage.setItem(
446
+ `__navgo_scroll:${location.href}`,
447
+ JSON.stringify({ x: scrollX || 0, y: scrollY || 0 }),
448
+ )
449
+ } catch {}
373
450
  const idx = this.#route_idx + (replace ? 0 : 1)
374
451
  const st = { ...state, __navgo: { ...state?.__navgo, shallow: true, idx } }
375
452
  history[(replace ? 'replace' : 'push') + 'State'](st, '', u.href)
@@ -379,12 +456,14 @@ export default class Navgo {
379
456
  })
380
457
  // Popstate handler checks state.__navgo.shallow and skips router processing
381
458
  this.#route_idx = idx
382
- // carry forward current scroll position for the shallow entry so Forward restores correctly
383
- this.#scroll.set(idx, { x: scrollX, y: scrollY })
459
+ // carry forward current window position for the shallow entry so Forward restores correctly
460
+ const m = this.#areas_pos.get(idx) || new Map()
461
+ m.set('window', { x: scrollX || 0, y: scrollY || 0 })
462
+ this.#areas_pos.set(idx, m)
384
463
  if (!replace) this.#clear_onward_history()
385
464
  // update current URL snapshot and notify
386
465
  this.#current.url = u
387
- this.#opts.url_changed?.(this.#current)
466
+ this.route.set(this.#current)
388
467
  }
389
468
 
390
469
  /** @param {string|URL} [url] @param {any} [state] */
@@ -492,6 +571,11 @@ export default class Navgo {
492
571
  return pat
493
572
  })
494
573
 
574
+ // TODO: deprecated, remove later
575
+ this.route.subscribe(() => {
576
+ this.#opts.url_changed?.(this.#current)
577
+ })
578
+
495
579
  ℹ('[🧭 init]', {
496
580
  base: this.#base,
497
581
  routes: this.#routes.length,
@@ -511,6 +595,8 @@ export default class Navgo {
511
595
  addEventListener('click', this.#click)
512
596
  addEventListener('beforeunload', this.#before_unload)
513
597
  addEventListener('hashchange', this.#on_hashchange)
598
+ this.#scroll_handler = throttle(this.#on_scroll, 100)
599
+ addEventListener('scroll', this.#scroll_handler, { capture: true })
514
600
 
515
601
  if (this.#opts.preload_on_hover) {
516
602
  // @ts-expect-error
@@ -535,6 +621,7 @@ export default class Navgo {
535
621
  }
536
622
 
537
623
  ℹ('[🧭 init]', 'initial goto')
624
+ if (this.#opts.attach_to_window) window.navgo = this
538
625
  return this.goto()
539
626
  }
540
627
  destroy() {
@@ -545,18 +632,12 @@ export default class Navgo {
545
632
  removeEventListener('mousedown', this.#tap)
546
633
  removeEventListener('beforeunload', this.#before_unload)
547
634
  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 })
635
+ removeEventListener('scroll', this.#scroll_handler, { capture: true })
636
+ this.#areas_pos.clear()
556
637
  }
557
638
 
558
639
  #clear_onward_history() {
559
- for (const k of this.#scroll.keys()) if (k > this.#route_idx) this.#scroll.delete(k)
640
+ for (const k of this.#areas_pos.keys()) if (k > this.#route_idx) this.#areas_pos.delete(k)
560
641
  ℹ('[🧭 scroll]', 'clear onward', { upto: this.#route_idx })
561
642
  }
562
643
 
@@ -579,18 +660,36 @@ export default class Navgo {
579
660
  // 1) On back/forward, restore saved position if available
580
661
  if (t === 'popstate') {
581
662
  const ev_state = ctx?.state ?? ctx?.event?.state
582
- const idx = ev_state?.__navgo?.idx
663
+ const st = ev_state?.__navgo
664
+ const idx = st?.idx
583
665
  const target_idx = typeof idx === 'number' ? idx : this.#route_idx - 1
584
666
  this.#route_idx = target_idx
585
- const pos = this.#scroll.get(target_idx)
667
+ const m = this.#areas_pos.get(target_idx)
668
+ let pos = st?.pos || m?.get?.('window')
669
+ if (!pos) {
670
+ try {
671
+ const k = `__navgo_scroll:${location.href}`
672
+ pos = JSON.parse(sessionStorage.getItem(k)) || null
673
+ sessionStorage.removeItem(k)
674
+ } catch {}
675
+ }
586
676
  if (pos) {
587
677
  scrollTo(pos.x, pos.y)
588
- ℹ('[🧭 scroll]', 'restore popstate', {
589
- idx: target_idx,
590
- ...pos,
591
- })
592
- return
678
+ // re-apply on next tick to resist late reflows (e.g. images)
679
+ setTimeout(() => scrollTo(pos.x, pos.y), 0)
680
+ ℹ('[🧭 scroll]', 'restore popstate', { idx: target_idx, ...pos })
593
681
  }
682
+ for (const [id, p] of m || []) {
683
+ if (id === 'window') continue
684
+ const sel = `[data-scroll-id="${CSS.escape(id)}"],` + `#${CSS.escape(id)}`
685
+ const el = document.querySelector(sel)
686
+ if (el) {
687
+ el.scrollTo?.(p.x, p.y)
688
+ el.scrollLeft = p.x
689
+ el.scrollTop = p.y
690
+ }
691
+ }
692
+ if (pos || m) return
594
693
  }
595
694
  // 2) If there is a hash, prefer anchor scroll
596
695
  if (hash && this.#scroll_to_hash(hash)) {
@@ -635,6 +734,27 @@ export default class Navgo {
635
734
  }
636
735
  }
637
736
 
737
+ function throttle(fn, ms) {
738
+ let t,
739
+ last = 0
740
+ return e => {
741
+ const now = Date.now()
742
+ if (now - last >= ms) {
743
+ last = now
744
+ fn(e)
745
+ } else {
746
+ clearTimeout(t)
747
+ t = setTimeout(
748
+ () => {
749
+ last = Date.now()
750
+ fn(e)
751
+ },
752
+ ms - (now - last),
753
+ )
754
+ }
755
+ }
756
+ }
757
+
638
758
  /** @typedef {import('./index.d.ts').Options} Options */
639
759
  /** @typedef {import('./index.d.ts').RouteTuple} RouteTuple */
640
760
  /** @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.5",
3
+ "version": "3.0.7",
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 test:e2e && 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": [
@@ -33,6 +34,9 @@
33
34
  "dependencies": {
34
35
  "regexparam": "^3.0.0"
35
36
  },
37
+ "peerDependencies": {
38
+ "svelte": "5"
39
+ },
36
40
  "devDependencies": {
37
41
  "@eslint/js": "^9.37.0",
38
42
  "@playwright/test": "^1.56.0",
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,17 +100,41 @@ 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 `push_state`/`replace_state`, 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. (deprecated; subscribe to `.route` instead.)
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`)
105
110
  - Delay in ms before hover preloading triggers.
106
111
  - `preload_on_hover`: `boolean` (default `true`)
107
112
  - When `false`, disables hover/touch preloading.
113
+ - `attach_to_window`: `boolean` (default `true`)
114
+ - When `true`, `init()` attaches the instance to `window.navgo` for convenience (e.g., devtools, simple Svelte scripts).
108
115
 
109
116
  Important: Navgo only processes routes that match your `base` path.
110
117
 
118
+ ### Instance stores
119
+
120
+ - `router.route` -- `Writable<{ url: URL; route: RouteTuple|null; params: Params }>`
121
+ - Readonly property that holds the current snapshot.
122
+ - Subscribe to react to changes; Navgo updates it on every URL change.
123
+ - `router.is_navigating` -- `Writable<boolean>`
124
+ - `true` while a navigation is in flight (between start and completion/cancel).
125
+
126
+ Example:
127
+
128
+ ```svelte
129
+ Current path: {$route.path}
130
+ <div class="request-indicator" class:active={$is_navigating}></div>
131
+
132
+ <script>
133
+ const router = new Navgo(...)
134
+ const {route, is_navigating} = router
135
+ </script>
136
+ ```
137
+
111
138
  ### Route Hooks
112
139
 
113
140
  - param_validators?: `Record<string, (value: string|null|undefined) => boolean>`
@@ -220,8 +247,8 @@ While listening, link clicks are intercepted and translated into `goto()` naviga
220
247
 
221
248
  In addition, `init()` wires preloading listeners (enabled by default) so route data can be fetched early:
222
249
 
223
- - `mousemove` (hover) after a short delay, hovering an in-app link triggers `preload(href)`.
224
- - `touchstart` and `mousedown` (tap) tapping or pressing on an in-app link also triggers `preload(href)`.
250
+ - `mousemove` (hover) -- after a short delay, hovering an in-app link triggers `preload(href)`.
251
+ - `touchstart` and `mousedown` (tap) -- tapping or pressing on an in-app link also triggers `preload(href)`.
225
252
 
226
253
  Preloading applies only to in-app anchors that match the configured [`base`](#base). You can tweak this behavior with the `preload_delay` and `preload_on_hover` options.
227
254
 
@@ -233,6 +260,26 @@ Notes:
233
260
 
234
261
  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
262
 
263
+ ### Scroll Restoration (areas)
264
+
265
+ Navgo caches/restores scroll positions for the window and any scrollable element that has a stable identifier:
266
+
267
+ - Give your element either an `id` or `data-scroll-id="..."`.
268
+ - Navgo listens to `scroll` globally (capture) and records positions per history entry.
269
+ - On `popstate`, it restores matching elements before paint.
270
+
271
+ Example:
272
+
273
+ ```html
274
+ <div id="pane" class="overflow-auto">...</div>
275
+ ```
276
+
277
+ Or with a custom id:
278
+
279
+ ```html
280
+ <div data-scroll-id="pane">...</div>
281
+ ```
282
+
236
283
  ### preload(uri)
237
284
 
238
285
  Returns: `Promise<unknown | void>`
@@ -262,10 +309,10 @@ This section explains, in detail, how navigation is processed: matching, hooks,
262
309
 
263
310
  ### Navigation Types
264
311
 
265
- - `link` user clicked an in-app `<a>` that matches `base`.
266
- - `goto` programmatic navigation via `router.goto(...)`.
267
- - `popstate` browser back/forward.
268
- - `leave` page is unloading (refresh, external navigation, tab close) via `beforeunload`.
312
+ - `link` -- user clicked an in-app `<a>` that matches `base`.
313
+ - `goto` -- programmatic navigation via `router.goto(...)`.
314
+ - `popstate` -- browser back/forward.
315
+ - `leave` -- page is unloading (refresh, external navigation, tab close) via `beforeunload`.
269
316
 
270
317
  The router passes the type to your route-level `before_route_leave(nav)` hook.
271
318
 
@@ -292,6 +339,8 @@ For `link` and `goto` navigations that match a route:
292
339
  → cache data by formatted path
293
340
  → history.push/replaceState(new URL)
294
341
  → after_navigate(nav)
342
+ → tick()? // optional app-provided await before scroll
343
+ → scroll restore/hash/top
295
344
  ```
296
345
 
297
346
  - If a loader throws/rejects, navigation continues and `after_navigate(..., with nav.to.data = { __error })` is delivered so UI can render an error state.
@@ -333,18 +382,26 @@ scroll flow
333
382
 
334
383
  ### Method-by-Method Semantics
335
384
 
336
- - `format(uri)` normalizes a path relative to `base`. Returns `false` when `uri` is outside of `base`.
337
- - `match(uri)` returns a Promise of `{ route, params } | null` using string/RegExp patterns and validators. Awaits an async `validate(params)` if provided.
338
- - `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`.
339
- - `init()` wires global listeners (`popstate`, `pushstate`, `replacestate`, click) and optional hover/tap preloading; immediately processes the current location.
340
- - `destroy()` removes listeners added by `init()`.
341
- - `preload(uri)` pre-executes a route's `loaders` for a path and caches the result; concurrent calls are deduped.
342
- - `push_state(url?, state?)` shallow push that updates the URL and `history.state` without route processing.
343
- - `replace_state(url?, state?)` shallow replace that updates the URL and `history.state` without route processing.
385
+ - `format(uri)` -- normalizes a path relative to `base`. Returns `false` when `uri` is outside of `base`.
386
+ - `match(uri)` -- returns a Promise of `{ route, params } | null` using string/RegExp patterns and validators. Awaits an async `validate(params)` if provided.
387
+ - `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`.
388
+ - `init()` -- wires global listeners (`popstate`, `pushstate`, `replacestate`, click) and optional hover/tap preloading; immediately processes the current location.
389
+ - `destroy()` -- removes listeners added by `init()`.
390
+ - `preload(uri)` -- pre-executes a route's `loaders` for a path and caches the result; concurrent calls are deduped.
391
+ - `push_state(url?, state?)` -- shallow push that updates the URL and `history.state` without route processing.
392
+ - `replace_state(url?, state?)` -- shallow replace that updates the URL and `history.state` without route processing.
344
393
 
345
394
  ### Built-in Validators
346
395
 
347
- - `Navgo.validators.int({ min?, max? })` `true` iff the value is an integer within optional bounds.
348
- - `Navgo.validators.one_of(iterable)` `true` iff the value is in the provided set.
396
+ - `Navgo.validators.int({ min?, max? })` -- `true` iff the value is an integer within optional bounds.
397
+ - `Navgo.validators.one_of(iterable)` -- `true` iff the value is in the provided set.
349
398
 
350
399
  Attach validators via a route tuple's `data.param_validators` to constrain matches.
400
+
401
+ # Credits
402
+
403
+ This router integrates ideas and small portions of code from these fantastic projects:
404
+
405
+ - SvelteKit -- https://github.com/sveltejs/kit
406
+ - navaid -- https://github.com/lukeed/navaid
407
+ - TanStack Router -- https://github.com/TanStack/router