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.
Files changed (4) hide show
  1. package/index.d.ts +5 -0
  2. package/index.js +136 -30
  3. package/package.json +3 -2
  4. 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
- // 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 {
@@ -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
- // save scroll for current index before shallow change
372
- 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 {}
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 scroll position for the shallow entry so Forward restores correctly
383
- 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)
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.#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)
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 idx = ev_state?.__navgo?.idx
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 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
+ }
586
662
  if (pos) {
587
663
  scrollTo(pos.x, pos.y)
588
- ℹ('[🧭 scroll]', 'restore popstate', {
589
- idx: target_idx,
590
- ...pos,
591
- })
592
- 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
+ }
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.5",
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