navgo 5.0.0 → 6.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/index.d.ts +23 -16
  2. package/index.js +83 -53
  3. package/package.json +3 -2
  4. package/readme.md +58 -55
package/index.d.ts CHANGED
@@ -3,23 +3,31 @@
3
3
  * For string patterns, missing optional params are `null`.
4
4
  * For RegExp named groups, missing groups may be `undefined`.
5
5
  */
6
- export type Params = Record<string, string | null | undefined>
6
+ export type RawParam = string | null | undefined
7
+ export type Params = Record<string, any>
7
8
 
8
9
  /** Built-in validator helpers shape. */
9
10
  export interface ValidatorHelpers {
10
- int(opts?: {
11
- min?: number | null
12
- max?: number | null
13
- }): (value: string | null | undefined) => boolean
14
- one_of(values: Iterable<string>): (value: string | null | undefined) => boolean
11
+ int(opts?: { min?: number | null; max?: number | null }): (value: RawParam) => boolean
12
+ one_of(values: Iterable<string>): (value: RawParam) => boolean
13
+ }
14
+
15
+ export type ParamRule =
16
+ | ((value: RawParam) => boolean)
17
+ | { validator?: (value: RawParam) => boolean; coercer?: (value: RawParam) => any }
18
+
19
+ export interface LoaderArgs {
20
+ route_entry: RouteTuple
21
+ url: URL
22
+ params: Params
15
23
  }
16
24
 
17
25
  /** Optional per-route hooks recognized by Navgo. */
18
26
  export interface Hooks {
19
- /** Validate params with custom per-param validators. Return `false` to skip a match. */
20
- param_validators?: Record<string, (value: string | null | undefined) => boolean>
27
+ /** Validate and/or coerce params (validator runs before coercer). */
28
+ param_rules?: Record<string, ParamRule>
21
29
  /** Load data for a route before navigation. May return a Promise or an array of values/promises. */
22
- loader?(params: Params): unknown | Promise<unknown> | Array<unknown | Promise<unknown>>
30
+ loader?(args: LoaderArgs): unknown | Promise<unknown> | Array<unknown | Promise<unknown>>
23
31
  /** Predicate used during match(); may be async. If it returns `false`, the route is skipped. */
24
32
  validate?(params: Params): boolean | Promise<boolean>
25
33
  /** Route-level navigation guard, called on the current route when leaving it. Synchronous only; call `nav.cancel()` to prevent navigation. */
@@ -46,8 +54,8 @@ export interface Navigation {
46
54
  cancel(): void
47
55
  }
48
56
 
49
- /** A route tuple: [pattern, data?]. The `data` may include {@link Hooks}. */
50
- export type RouteTuple<T = unknown> = [pattern: string | RegExp, data: T]
57
+ /** A route tuple: [pattern, data?, extra?]. The `data`/`extra` may include {@link Hooks}. */
58
+ export type RouteTuple<T = unknown, U = unknown> = [pattern: string | RegExp, data?: T, extra?: U]
51
59
 
52
60
  /** Result of calling `router.match(url)` */
53
61
  export interface MatchResult<T = unknown> {
@@ -86,11 +94,10 @@ export interface Options {
86
94
  * scrolling lands on the correct elements.
87
95
  */
88
96
  tick?: () => void | Promise<void>
89
- /** Global hook fired whenever the URL changes.
90
- * Triggers for shallow pushes/replaces, hash changes, popstate-shallow, 404s, and full navigations.
91
- * Receives the router's current snapshot (eg `{ url: URL, route: RouteTuple|null, params: Params }`).
92
- */
93
- url_changed?(payload: any): void
97
+ /** When `false`, do not scroll to top on non-hash navigations. Default true. */
98
+ scroll_to_top?: boolean
99
+ /** When `true`, sets `aria-current="page"` on active in-app links. Default false. */
100
+ aria_current?: boolean
94
101
  }
95
102
 
96
103
  /** Navgo default export: class-based router. */
package/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { throttle } from 'es-toolkit'
1
2
  import { parse } from 'regexparam'
2
3
  import { tick } from 'svelte'
3
4
  import { writable } from 'svelte/store'
@@ -12,8 +13,9 @@ export default class Navgo {
12
13
  preload_on_hover: true,
13
14
  before_navigate: undefined,
14
15
  after_navigate: undefined,
15
- url_changed: undefined,
16
16
  tick,
17
+ scroll_to_top: true,
18
+ aria_current: false,
17
19
  attach_to_window: true,
18
20
  }
19
21
  /** @type {Array<{ pattern: RegExp, keys: string[]|null, data: RouteTuple }>} */
@@ -105,6 +107,7 @@ export default class Navgo {
105
107
  ℹ(' - [🧭 event:popstate]', 'same path+search; skip loader')
106
108
  this.#apply_scroll(ev)
107
109
  this.route.set(this.#current)
110
+ this.#update_active_links()
108
111
  return
109
112
  }
110
113
  // Explicit shallow entries (pushState/replaceState) regardless of path
@@ -113,6 +116,7 @@ export default class Navgo {
113
116
  ℹ(' - [🧭 event:popstate]', 'shallow entry; skip loader')
114
117
  this.#apply_scroll(ev)
115
118
  this.route.set(this.#current)
119
+ this.#update_active_links()
116
120
  return
117
121
  }
118
122
 
@@ -138,7 +142,7 @@ export default class Navgo {
138
142
  if (pos) {
139
143
  scrollTo(pos.x, pos.y)
140
144
  ℹ('[🧭 scroll]', 'restore hash-back', { idx: this.#route_idx, ...pos })
141
- } else {
145
+ } else if (this.#opts.scroll_to_top) {
142
146
  // no saved position for previous entry — default to top
143
147
  scrollTo(0, 0)
144
148
  ℹ('[🧭 scroll]', 'hash-back -> top')
@@ -148,6 +152,7 @@ export default class Navgo {
148
152
  // update current URL snapshot and notify
149
153
  this.#current.url = new URL(location.href)
150
154
  this.route.set(this.#current)
155
+ this.#update_active_links()
151
156
  }
152
157
 
153
158
  /** @type {any} */
@@ -200,7 +205,7 @@ export default class Navgo {
200
205
  will_unload: true,
201
206
  event: ev,
202
207
  })
203
- this.#current.route?.[1]?.before_route_leave?.(nav)
208
+ this.#get_hooks(this.#current.route)?.before_route_leave?.(nav)
204
209
  if (nav.cancelled) {
205
210
  ℹ('[🧭 navigate]', 'cancelled by before_route_leave during unload')
206
211
  ev.preventDefault()
@@ -245,17 +250,39 @@ export default class Navgo {
245
250
  : null
246
251
  }
247
252
 
248
- #check_param_validators(param_validators, params) {
249
- for (const k in param_validators) {
250
- const fn = param_validators[k]
251
- if (typeof fn !== 'function') continue
252
- if (!fn(params[k])) return false
253
+ #update_active_links() {
254
+ if (!this.#opts.aria_current) return
255
+ const cur = this.format(this.#current.url?.pathname)
256
+ if (!cur) return
257
+ for (const a of document.querySelectorAll('a[href]')) {
258
+ const href = a.getAttribute('href')
259
+ if (href[0] === '#') continue
260
+ const link_path = href && this.#resolve_url_and_path(href)?.path
261
+ if (link_path === cur) a.setAttribute('aria-current', 'page')
262
+ else if (a.getAttribute('aria-current') === 'page') a.removeAttribute('aria-current')
263
+ }
264
+ }
265
+
266
+ #get_hooks(route) {
267
+ if (!route) return {}
268
+ const a = route[1]
269
+ const b = route[2]
270
+ if (!b) return a || {}
271
+ const hooks = { ...(a || {}), ...b }
272
+ const ar = a?.param_rules
273
+ const br = b?.param_rules
274
+ if (ar || br) {
275
+ const out = {}
276
+ const norm = r => (typeof r === 'function' ? { validator: r } : r || {})
277
+ for (const k in ar || {}) out[k] = norm(ar[k])
278
+ for (const k in br || {}) out[k] = { ...out[k], ...norm(br[k]) }
279
+ hooks.param_rules = out
253
280
  }
254
- return true
281
+ return hooks
255
282
  }
256
283
 
257
284
  async #run_loader(route, url, params) {
258
- const ret_val = route[1].loader?.({ route_entry: route, url, params })
285
+ const ret_val = this.#get_hooks(route)?.loader?.({ route_entry: route, url, params })
259
286
  return Array.isArray(ret_val) ? Promise.all(ret_val) : ret_val
260
287
  }
261
288
 
@@ -316,7 +343,7 @@ export default class Navgo {
316
343
  //
317
344
  // before_route_leave
318
345
  //
319
- this.#current.route?.[1]?.before_route_leave?.(nav)
346
+ this.#get_hooks(this.#current.route)?.before_route_leave?.(nav)
320
347
  if (nav.cancelled) {
321
348
  // use history.go to cancel the nav, and jump back to where we are
322
349
  if (is_popstate) {
@@ -353,11 +380,23 @@ export default class Navgo {
353
380
  let data
354
381
  if (hit) {
355
382
  const pre = this.#preloads.get(path)
356
- data =
357
- pre?.data ??
358
- (await (pre?.promise || this.#run_loader(hit.route, url, hit.params)).catch(e => ({
359
- __error: e,
360
- })))
383
+ try {
384
+ data =
385
+ pre?.data ??
386
+ (await (pre?.promise || this.#run_loader(hit.route, url, hit.params)))
387
+ } catch (e) {
388
+ this.#preloads.delete(path)
389
+ ℹ('[🧭 loader]', 'error; abort', { path, error: e })
390
+ if (is_popstate) {
391
+ const new_idx = ev_param?.state?.__navgo?.idx
392
+ if (new_idx != null) {
393
+ const delta = new_idx - this.#route_idx
394
+ if (delta) history.go(-delta)
395
+ }
396
+ }
397
+ if (nav_id === this.#nav_active) this.is_navigating.set(false)
398
+ return
399
+ }
361
400
  this.#preloads.delete(path)
362
401
  ℹ('[🧭 loader]', pre ? 'using preloaded data' : 'loaded', {
363
402
  path,
@@ -425,6 +464,7 @@ export default class Navgo {
425
464
  // allow frameworks to flush DOM before scrolling
426
465
  await this.#opts.tick?.()
427
466
 
467
+ this.#update_active_links()
428
468
  this.#apply_scroll(nav)
429
469
  this.is_navigating.set(false)
430
470
  }
@@ -465,6 +505,7 @@ export default class Navgo {
465
505
  // update current URL snapshot and notify
466
506
  this.#current.url = u
467
507
  this.route.set(this.#current)
508
+ this.#update_active_links()
468
509
  }
469
510
 
470
511
  /** @param {string|URL} [url] @param {any} [state] */
@@ -535,15 +576,28 @@ export default class Navgo {
535
576
  }
536
577
 
537
578
  // per-route validators and optional async validate()
538
- const hooks = obj.data[1]
539
- if (
540
- hooks.param_validators &&
541
- !this.#check_param_validators(hooks.param_validators, params)
542
- ) {
543
- ℹ('[🧭 match]', 'skip: param_validators', {
544
- pattern: obj.data?.[0],
545
- })
546
- continue
579
+ const hooks = this.#get_hooks(obj.data)
580
+ if (hooks.param_rules) {
581
+ let ok = true
582
+ for (const k in hooks.param_rules) {
583
+ const param_rule = hooks.param_rules[k]
584
+ const param_validator =
585
+ typeof param_rule === 'function' ? param_rule : param_rule?.validator
586
+ if (typeof param_validator === 'function' && !param_validator(params[k])) {
587
+ ok = false
588
+ break
589
+ }
590
+ }
591
+ if (!ok) {
592
+ ℹ('[🧭 match]', 'skip: param_rules', { pattern: obj.data?.[0] })
593
+ continue
594
+ }
595
+ for (const k in hooks.param_rules) {
596
+ const param_rule = hooks.param_rules[k]
597
+ const param_coercer =
598
+ typeof param_rule === 'function' ? null : param_rule?.coercer
599
+ if (typeof param_coercer === 'function') params[k] = param_coercer(params[k])
600
+ }
547
601
  }
548
602
  if (hooks.validate && !(await hooks.validate(params))) {
549
603
  ℹ('[🧭 match]', 'skip: validate', { pattern: obj.data?.[0] })
@@ -572,11 +626,6 @@ export default class Navgo {
572
626
  return pat
573
627
  })
574
628
 
575
- // TODO: deprecated, remove later
576
- this.route.subscribe(() => {
577
- this.#opts.url_changed?.(this.#current)
578
- })
579
-
580
629
  ℹ('[🧭 init]', {
581
630
  base: this.#base,
582
631
  routes: this.#routes.length,
@@ -699,8 +748,10 @@ export default class Navgo {
699
748
  return
700
749
  }
701
750
  // 3) Default: scroll to top for new navigations
702
- scrollTo(0, 0)
703
- ('[🧭 scroll]', 'top')
751
+ if (this.#opts.scroll_to_top) {
752
+ scrollTo(0, 0)
753
+ ℹ('[🧭 scroll]', 'top')
754
+ }
704
755
  })
705
756
  }
706
757
 
@@ -736,27 +787,6 @@ export default class Navgo {
736
787
  }
737
788
  }
738
789
 
739
- function throttle(fn, ms) {
740
- let t,
741
- last = 0
742
- return e => {
743
- const now = Date.now()
744
- if (now - last >= ms) {
745
- last = now
746
- fn(e)
747
- } else {
748
- clearTimeout(t)
749
- t = setTimeout(
750
- () => {
751
- last = Date.now()
752
- fn(e)
753
- },
754
- ms - (now - last),
755
- )
756
- }
757
- }
758
- }
759
-
760
790
  /** @typedef {import('./index.d.ts').Options} Options */
761
791
  /** @typedef {import('./index.d.ts').RouteTuple} RouteTuple */
762
792
  /** @typedef {import('./index.d.ts').MatchResult} MatchResult */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "navgo",
3
- "version": "5.0.0",
3
+ "version": "6.0.0-beta.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mustafa0x/navgo.git"
@@ -18,7 +18,7 @@
18
18
  "build": "perl -0777 -i -pe 's/console.debug\\(...args\\)/{}/g' index.js",
19
19
  "prepublishOnly": "pnpm run build",
20
20
  "test": "vitest run index.test.js",
21
- "test:e2e": "playwright test",
21
+ "test:e2e": "playwright test --project=chromium",
22
22
  "start:testsite": "pnpm vite dev test/site --port 5714",
23
23
  "types": "tsc -p test/types"
24
24
  },
@@ -32,6 +32,7 @@
32
32
  "router"
33
33
  ],
34
34
  "dependencies": {
35
+ "es-toolkit": "^1.42.0",
35
36
  "regexparam": "^3.0.0"
36
37
  },
37
38
  "peerDependencies": {
package/readme.md CHANGED
@@ -8,62 +8,64 @@ $ pnpm install --dev navgo
8
8
 
9
9
  ```js
10
10
  import Navgo from 'navgo'
11
+ import {mount} from 'svelte'
12
+
13
+ import App from './App.svelte'
14
+ import * as HomeRoute from './routes/Home.svelte'
15
+ import * as ReaderRoute from './routes/Reader.svelte'
16
+ import * as AccountRoute from './routes/Account.svelte'
17
+ import * as AdminRoute from './routes/Admin.svelte'
18
+ import * as DebugRoute from './routes/Debug.svelte'
11
19
 
12
- // Define routes up front (strings or RegExp)
13
20
  const routes = [
14
- ['/', {}],
15
- ['/users/:username', {}],
16
- ['/books/*', {}],
17
- [/articles\/(?<year>[0-9]{4})/, {}],
18
- [/privacy|privacy-policy/, {}],
21
+ ['/', HomeRoute],
22
+ ['/:book_id', ReaderRoute],
23
+ ['/account', AccountRoute],
19
24
  [
20
- '/admin',
25
+ '/admin/:id',
26
+ AdminRoute,
21
27
  {
22
- // constrain params with built-ins or your own
23
- param_validators: {
24
- /* id: Navgo.validators.int({ min: 1 }) */
28
+ // constrain/coerce params
29
+ param_rules: {
30
+ id: { validator: Navgo.validators.int({ min: 1 }), coercer: Number },
25
31
  },
26
32
  // load data before URL changes; result goes to after_navigate(...)
27
- loader: params => fetch('/api/admin').then(r => r.json()),
33
+ loader: ({ params }) => fetch(`/api/admin/${params.id}`).then(r => r.json()),
28
34
  // per-route guard; cancel synchronously to block nav
29
35
  before_route_leave(nav) {
30
- if ((nav.type === 'link' || nav.type === 'nav') && !confirm('Enter admin?')) {
36
+ if ((nav.type === 'link' || nav.type === 'goto') && !confirm('Enter admin?')) {
31
37
  nav.cancel()
32
38
  }
33
39
  },
34
40
  },
35
41
  ],
36
42
  ]
43
+ if (window.__DEBUG__) routes.push(['/debug', DebugRoute])
44
+
45
+ const props = $state({
46
+ component: null,
47
+ route_data: null,
48
+ is_404: false,
49
+ })
50
+
51
+ function after_navigate(nav) {
52
+ props.is_404 = nav.to?.data?.__error?.status === 404
53
+ props.route_data = nav.to?.data ?? null
54
+ props.component = nav.to?.route?.[1]?.default || null
55
+ }
37
56
 
38
- // Create router with options + callbacks
39
57
  const router = new Navgo(routes, {
40
- base: '/',
41
58
  before_navigate(nav) {
42
59
  // app-level hook before loader/URL update; may cancel
43
60
  console.log('before_navigate', nav.type, '→', nav.to?.url.pathname)
44
61
  },
45
- after_navigate(nav) {
46
- // called after routing completes; nav.to.data holds loader result
47
- if (nav.to?.data?.__error?.status === 404) {
48
- console.log('404 for', nav.to.url.pathname)
49
- return
50
- }
51
-
52
- console.log('after_navigate', nav.to?.url.pathname, nav.to?.data)
53
- },
54
- // let your framework flush DOM before scroll
55
- // e.g. in Svelte: `import { tick } from 'svelte'`
56
- tick: tick,
57
- url_changed(cur) {
58
- // fires on shallow/hash/popstate-shallow/404 and full navigations
59
- // `cur` is the router snapshot: { url: URL, route, params }
60
- console.log('url_changed', cur.url.href)
61
- },
62
+ after_navigate,
62
63
  })
63
64
 
64
65
  // Long-lived router: history + <a> bindings
65
66
  // Also immediately processes the current location
66
67
  router.init()
68
+ mount(App, {target: document.body, props})
67
69
  ```
68
70
 
69
71
  ## API
@@ -74,9 +76,9 @@ Returns: `Router`
74
76
 
75
77
  #### `routes`
76
78
 
77
- Type: `Array<[pattern: string | RegExp, data: any]>`
79
+ Type: `Array<[pattern: string | RegExp, data?: any, extra?: any]>`
78
80
 
79
- Each route is a tuple whose first item is the pattern and whose second item is hooks (see “Route Hooks”). Pass `{}` when no hooks are needed. Navgo returns this tuple back to you unchanged via `onRoute`.
81
+ Each route is a tuple whose first item is the pattern and whose second item is hooks (see “Route Hooks”). The optional third item is extra hooks and is merged with the second item (third wins; `param_rules` are merged by key).
80
82
 
81
83
  Supported pattern types:
82
84
 
@@ -102,10 +104,10 @@ Notes:
102
104
  - App-level hook called after routing completes (URL updated, data loaded). `nav.to.data` holds any loader data.
103
105
  - `tick`: `() => void | Promise<void>`
104
106
  - Awaited after `after_navigate` and before scroll handling; useful for frameworks to flush DOM so anchor/top scrolling lands correctly.
105
- - `url_changed`: `(snapshot: any) => void`
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.)
107
- - Receives the router's current snapshot: an object like `{ url: URL, route: RouteTuple|null, params: Params }`.
108
- - The snapshot type is intentionally `any` and may evolve without a breaking change.
107
+ - `scroll_to_top`: `boolean` (default `true`)
108
+ - When `false`, skips the default top scroll for non-hash navigations.
109
+ - `aria_current`: `boolean` (default `false`)
110
+ - When `true`, sets `aria-current="page"` on active in-app links.
109
111
  - `preload_delay`: `number` (default `20`)
110
112
  - Delay in ms before hover preloading triggers.
111
113
  - `preload_on_hover`: `boolean` (default `true`)
@@ -137,10 +139,11 @@ const {route, is_navigating} = router
137
139
 
138
140
  ### Route Hooks
139
141
 
140
- - param_validators?: `Record<string, (value: string|null|undefined) => boolean>`
141
- - Validate params (e.g., `id: Navgo.validators.int({ min: 1 })`). Any `false` result skips the route.
142
- - loader?(params): `unknown | Promise | Array<unknown|Promise>`
143
- - Run before URL changes on `link`/`nav`. Results are cached per formatted path and forwarded to `after_navigate`.
142
+ - param_rules?: `Record<string, ((value: string|null|undefined) => boolean) | { validator?: (value: string|null|undefined) => boolean; coercer?: (value: string|null|undefined) => any }>`
143
+ - Single place for param rules. If the value is a function, it is treated as a validator.
144
+ - Validators run on raw params; coercers run after validators and may transform params before `validate(...)`/`loader`.
145
+ - loader?({ params }): `unknown | Promise | Array<unknown|Promise>`
146
+ - Run before URL changes on `link`/`goto`. Results are cached per formatted path and forwarded to `after_navigate`.
144
147
  - validate?(params): `boolean | Promise<boolean>`
145
148
  - Predicate called during matching. If it returns or resolves to `false`, the route is skipped.
146
149
  - before_route_leave?(nav): `(nav: Navigation) => void`
@@ -150,7 +153,7 @@ The `Navigation` object contains:
150
153
 
151
154
  ```ts
152
155
  {
153
- type: 'link' | 'nav' | 'popstate' | 'leave',
156
+ type: 'link' | 'goto' | 'popstate' | 'leave',
154
157
  from: { url, params, route } | null,
155
158
  to: { url, params, route } | null,
156
159
  will_unload: boolean,
@@ -164,7 +167,7 @@ The `Navigation` object contains:
164
167
 
165
168
  - Router calls `before_navigate` on the current route (leave).
166
169
  - Call `nav.cancel()` synchronously to cancel.
167
- - For `link`/`nav`, it stops before URL change.
170
+ - For `link`/`goto`, it stops before URL change.
168
171
  - For `popstate`, cancellation causes an automatic `history.go(...)` to revert to the previous index.
169
172
  - For `leave`, cancellation triggers the native “Leave site?” dialog (behavior is browser-controlled).
170
173
 
@@ -173,15 +176,15 @@ Example:
173
176
  ```js
174
177
  const routes = [
175
178
  [
176
- '/admin',
179
+ '/account/:account_id',
177
180
  {
178
- param_validators: {
179
- /* ... */
181
+ param_rules: {
182
+ account_id: {validator: Navgo.validators.int({min: 1}), coercer: Number},
180
183
  },
181
- loader: params => fetch('/api/admin/stats').then(r => r.json()),
184
+ loader: ({params}) => fetch(`/api/account/${params.account_id}`).then(r => r.json()),
182
185
  before_route_leave(nav) {
183
- if (nav.type === 'link' || nav.type === 'nav') {
184
- if (!confirm('Enter admin area?')) nav.cancel()
186
+ if (nav.type === 'link' || nav.type === 'goto') {
187
+ if (!confirm('Leave account settings?')) nav.cancel()
185
188
  }
186
189
  },
187
190
  },
@@ -323,7 +326,7 @@ The router passes the type to your route-level `before_route_leave(nav)` hook.
323
326
  - Named params from string patterns populate `params` with `string` values; optional params that do not appear are `null`.
324
327
  - Wildcards use the `'*'` key.
325
328
  - RegExp named groups also populate `params`; omitted groups can be `undefined`.
326
- - If `data.param_validators` is present, each `params[k]` is validated; any `false` result skips that route.
329
+ - If `data.param_rules` is present, each `params[k]` validator runs first, then coercers run to transform params.
327
330
  - If `data.validate(params)` returns or resolves to `false`, the route is also skipped.
328
331
 
329
332
  ### Data Flow
@@ -335,7 +338,7 @@ For `link` and `goto` navigations that match a route:
335
338
  → before_route_leave({ type }) // per-route guard
336
339
  → before_navigate(nav) // app-level start
337
340
  → cancelled? yes → stop
338
- → no → run loader(params) // may be value, Promise, or Promise[]
341
+ → no → run loader({ params }) // may be value, Promise, or Promise[]
339
342
  → cache data by formatted path
340
343
  → history.push/replaceState(new URL)
341
344
  → after_navigate(nav)
@@ -343,8 +346,8 @@ For `link` and `goto` navigations that match a route:
343
346
  → scroll restore/hash/top
344
347
  ```
345
348
 
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.
347
- - For `popstate`, the route's `loader` runs before completion so content matches the target entry; this improves scroll restoration. Errors are delivered via `after_navigate` with `nav.to.data = { __error }`.
349
+ - If a loader throws/rejects, navigation aborts (no URL/history change, no `after_navigate`).
350
+ - For `popstate`, the route's `loader` runs before completion so content matches the target entry; this improves scroll restoration. If the loader throws, the popstate is reverted.
348
351
 
349
352
  ### Shallow Routing
350
353
 
@@ -367,7 +370,7 @@ To enable `popstate` cancellation, Navgo stores a monotonic `idx` in `history.st
367
370
  Navgo manages scroll manually (sets `history.scrollRestoration = 'manual'`) and applies SvelteKit-like behavior:
368
371
 
369
372
  - Saves the current scroll position for the active history index.
370
- - On `link`/`nav` (after route commit):
373
+ - On `link`/`goto` (after route commit):
371
374
  - If the URL has a `#hash`, scroll to the matching element `id` or `[name="..."]`.
372
375
  - Otherwise, scroll to the top `(0, 0)`.
373
376
  - On `popstate`: restore the saved position for the target history index; if not found but there is a `#hash`, scroll to the anchor instead.
@@ -396,7 +399,7 @@ scroll flow
396
399
  - `Navgo.validators.int({ min?, max? })` -- `true` iff the value is an integer within optional bounds.
397
400
  - `Navgo.validators.one_of(iterable)` -- `true` iff the value is in the provided set.
398
401
 
399
- Attach validators via a route tuple's `data.param_validators` to constrain matches.
402
+ Attach validators via a route tuple's `data.param_rules` rules.
400
403
 
401
404
  # Credits
402
405