@real-router/svelte 0.11.0 → 0.13.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.
package/README.md CHANGED
@@ -200,7 +200,7 @@ All other props are spread onto the `<a>` element.
200
200
  <Link routeName="settings" hash="account">Account</Link>
201
201
  ```
202
202
 
203
- Active class is hash-aware — only the matching tab lights up. Live demo: [`examples/web/react/link-hash/`](../../examples/web/react/link-hash/) — behavior is identical across adapters, only template syntax differs. See the [Hash Fragment Support](https://github.com/greydragon888/real-router/wiki/Hash) wiki page for the full surface.
203
+ Active class is hash-aware — only the matching tab lights up. Live demo: [`examples/web/react/hash-examples/link-hash/`](../../examples/web/react/hash-examples/link-hash/) — behavior is identical across adapters, only template syntax differs. See the [Hash Fragment Support](https://github.com/greydragon888/real-router/wiki/Hash) wiki page for the full surface.
204
204
 
205
205
  ### `<Lazy>`
206
206
 
@@ -473,7 +473,23 @@ Opt-in preservation of scroll position across navigations:
473
473
  </RouterProvider>
474
474
  ```
475
475
 
476
- Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"native"`. Custom containers via `scrollContainer: () => HTMLElement | null`. Lifecycle tied to the provider — created on mount, destroyed on unmount. See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for details.
476
+ Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"native"`. Custom containers via `scrollContainer: () => HTMLElement | null`. Lifecycle tied to the provider — created on mount, destroyed on unmount. Under `@real-router/browser-plugin`, replace transitions now preserve scroll position and programmatic reloads restore from `sessionStorage` (portable via `state.transition.replace` / `state.transition.reload`). See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for the full behaviour matrix.
477
+
478
+ ## Scroll Spy
479
+
480
+ Opt-in router-coordinated `IntersectionObserver` scroll spy — the URL hash tracks the topmost visible anchor as the user scrolls, syncing `state.context.url.hash` so sibling `<Link hash>` highlights stay current:
481
+
482
+ ```svelte
483
+ <RouterProvider {router} scrollSpy={{ selector: "[id]:is(h2,h3)" }}>
484
+ <!-- Your app -->
485
+ </RouterProvider>
486
+ ```
487
+
488
+ Emits a forced same-route transition with `{ hash, replace: true, force: true, hashChange: true }` — same write API as `<Link hash>` (#532), `replace: true` so spy doesn't pollute history. Anti-flicker via `isTransitioning` + `coolingDown` gates with `selfEmitting` guard. Hardcoded internals: rAF + 150 ms debounce, MutationObserver 250 ms.
489
+
490
+ Options: `{ selector: string, rootMargin?: string, scrollContainer?: () => HTMLElement | null }`. Reactive via `$effect` — primitive fields (`selector`, `rootMargin`) wrapped in `$derived`, so inline objects with the same values don't thrash; `scrollContainer` getter pulled via `untrack` (identity changes don't retrigger). Empty `selector` / `undefined` = off. SSR / browsers without `IntersectionObserver` = NOOP. Requires `browser-plugin` or `navigation-plugin` (hash-plugin / memory-plugin → warn-once + NOOP).
491
+
492
+ Behaviour is identical to the React adapter — see the [React Scroll Spy demo](../../examples/web/react/hash-examples/scroll-spy/) (12 sections, TOC sidebar, 10 e2e scenarios) and the [Scroll Spy guide](https://github.com/greydragon888/real-router/wiki/Scroll-Spy).
477
493
 
478
494
  ## View Transitions
479
495
 
@@ -491,7 +507,7 @@ Reactive via `$effect` — toggling the prop creates/destroys the utility. No-op
491
507
 
492
508
  Full documentation: [Wiki](https://github.com/greydragon888/real-router/wiki)
493
509
 
494
- - [RouterProvider](https://github.com/greydragon888/real-router/wiki/RouterProvider) · [RouteView](https://github.com/greydragon888/real-router/wiki/RouteView) · [RouterErrorBoundary](https://github.com/greydragon888/real-router/wiki/RouterErrorBoundary) · [Link](https://github.com/greydragon888/real-router/wiki/Link) · [Scroll Restoration](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) · [View Transitions](https://github.com/greydragon888/real-router/wiki/View-Transitions)
510
+ - [RouterProvider](https://github.com/greydragon888/real-router/wiki/RouterProvider) · [RouteView](https://github.com/greydragon888/real-router/wiki/RouteView) · [RouterErrorBoundary](https://github.com/greydragon888/real-router/wiki/RouterErrorBoundary) · [Link](https://github.com/greydragon888/real-router/wiki/Link) · [Scroll Restoration](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) · [Scroll Spy](https://github.com/greydragon888/real-router/wiki/Scroll-Spy) · [View Transitions](https://github.com/greydragon888/real-router/wiki/View-Transitions)
495
511
  - [useRouter](https://github.com/greydragon888/real-router/wiki/useRouter) · [useRoute](https://github.com/greydragon888/real-router/wiki/useRoute) · [useRouteNode](https://github.com/greydragon888/real-router/wiki/useRouteNode) · [useNavigator](https://github.com/greydragon888/real-router/wiki/useNavigator) · [useRouteUtils](https://github.com/greydragon888/real-router/wiki/useRouteUtils) · [useRouterTransition](https://github.com/greydragon888/real-router/wiki/useRouterTransition) · [useRouteExit](https://github.com/greydragon888/real-router/wiki/useRouteExit) · [useRouteEnter](https://github.com/greydragon888/real-router/wiki/useRouteEnter)
496
512
 
497
513
  ## Examples
@@ -4,6 +4,7 @@
4
4
  import {
5
5
  createRouteAnnouncer,
6
6
  createScrollRestoration,
7
+ createScrollSpy,
7
8
  createViewTransitions,
8
9
  } from "./dom-utils";
9
10
  import { setContext, untrack } from "svelte";
@@ -12,7 +13,7 @@
12
13
  import { createRouteContext } from "./createRouteContext.svelte";
13
14
  import { NAVIGATOR_KEY, ROUTE_KEY, ROUTER_KEY } from "./context";
14
15
 
15
- import type { ScrollRestorationOptions } from "./dom-utils";
16
+ import type { ScrollRestorationOptions, ScrollSpyOptions } from "./dom-utils";
16
17
  import type { Router } from "@real-router/core";
17
18
  import type { Snippet } from "svelte";
18
19
 
@@ -21,13 +22,15 @@
21
22
  children,
22
23
  announceNavigation,
23
24
  scrollRestoration,
25
+ scrollSpy,
24
26
  viewTransitions,
25
27
  }: {
26
28
  router: Router;
27
29
  children: Snippet;
28
- announceNavigation?: boolean;
29
- scrollRestoration?: ScrollRestorationOptions;
30
- viewTransitions?: boolean;
30
+ announceNavigation?: boolean | undefined;
31
+ scrollRestoration?: ScrollRestorationOptions | undefined;
32
+ scrollSpy?: ScrollSpyOptions | undefined;
33
+ viewTransitions?: boolean | undefined;
31
34
  } = $props();
32
35
 
33
36
  $effect(() => {
@@ -73,6 +76,23 @@
73
76
  return () => sr.destroy();
74
77
  });
75
78
 
79
+ const spyEnabled = $derived(
80
+ scrollSpy !== undefined && scrollSpy.selector !== "",
81
+ );
82
+ const spySelector = $derived(scrollSpy?.selector);
83
+ const spyRootMargin = $derived(scrollSpy?.rootMargin);
84
+
85
+ $effect(() => {
86
+ if (!spyEnabled || !spySelector) return;
87
+ void spyRootMargin;
88
+ const spy = createScrollSpy(router, {
89
+ selector: spySelector,
90
+ rootMargin: spyRootMargin,
91
+ scrollContainer: untrack(() => scrollSpy?.scrollContainer),
92
+ });
93
+ return () => spy.destroy();
94
+ });
95
+
76
96
  $effect(() => {
77
97
  if (!viewTransitions) return;
78
98
  const vt = createViewTransitions(router);
@@ -1,12 +1,13 @@
1
- import type { ScrollRestorationOptions } from "./dom-utils";
1
+ import type { ScrollRestorationOptions, ScrollSpyOptions } from "./dom-utils";
2
2
  import type { Router } from "@real-router/core";
3
3
  import type { Snippet } from "svelte";
4
4
  type $$ComponentProps = {
5
5
  router: Router;
6
6
  children: Snippet;
7
- announceNavigation?: boolean;
8
- scrollRestoration?: ScrollRestorationOptions;
9
- viewTransitions?: boolean;
7
+ announceNavigation?: boolean | undefined;
8
+ scrollRestoration?: ScrollRestorationOptions | undefined;
9
+ scrollSpy?: ScrollSpyOptions | undefined;
10
+ viewTransitions?: boolean | undefined;
10
11
  };
11
12
  declare const RouterProvider: import("svelte").Component<$$ComponentProps, {}, "">;
12
13
  type RouterProvider = ReturnType<typeof RouterProvider>;
@@ -1,9 +1,11 @@
1
1
  export { createDirectionTracker } from "./direction-tracker.js";
2
2
  export { createRouteAnnouncer } from "./route-announcer.js";
3
3
  export { createScrollRestoration } from "./scroll-restore.js";
4
+ export { createScrollSpy } from "./scroll-spy.js";
4
5
  export { createViewTransitions } from "./view-transitions.js";
5
6
  export { shouldNavigate, buildHref, buildActiveClassName, navigateWithHash, shallowEqual, applyLinkA11y, } from "./link-utils.js";
6
7
  export type { RouteAnnouncerOptions } from "./route-announcer.js";
7
8
  export type { ScrollRestorationOptions, ScrollRestorationMode, } from "./scroll-restore.js";
9
+ export type { ScrollSpy, ScrollSpyOptions } from "./scroll-spy.js";
8
10
  export type { DirectionTracker } from "./direction-tracker.js";
9
11
  export type { ViewTransitions } from "./view-transitions.js";
@@ -1,5 +1,6 @@
1
1
  export { createDirectionTracker } from "./direction-tracker.js";
2
2
  export { createRouteAnnouncer } from "./route-announcer.js";
3
3
  export { createScrollRestoration } from "./scroll-restore.js";
4
+ export { createScrollSpy } from "./scroll-spy.js";
4
5
  export { createViewTransitions } from "./view-transitions.js";
5
6
  export { shouldNavigate, buildHref, buildActiveClassName, navigateWithHash, shallowEqual, applyLinkA11y, } from "./link-utils.js";
@@ -1,4 +1,11 @@
1
1
  const DEFAULT_STORAGE_KEY = "real-router:scroll";
2
+ // Bounded retry budget for resolving a late-mounting scroll container on the
3
+ // restore path. A per-route container (e.g. an `overflow:auto` div rendered
4
+ // only on one route) can be committed to the DOM a few frames after the
5
+ // navigation settles — heavier routes paint later than the subscribe's rAF.
6
+ // ~10 frames (≈160ms at 60fps) comfortably covers a React commit of a large
7
+ // route without being perceptible. See the doc-block on `restorePos`.
8
+ const RESTORE_RETRY_FRAMES = 10;
2
9
  const NOOP_INSTANCE = Object.freeze({
3
10
  destroy: () => {
4
11
  /* no-op */
@@ -80,6 +87,65 @@ export function createScrollRestoration(router, options) {
80
87
  globalThis.scrollTo({ top, left: 0, behavior });
81
88
  }
82
89
  };
90
+ // Restore path (back / traverse / reload). Unlike `writePos`, this tolerates a
91
+ // scroll container that both MOUNTS and LAYS OUT a few frames AFTER the
92
+ // navigation settles.
93
+ //
94
+ // The capture-side `readPos` always runs against an already-mounted DOM (the
95
+ // route being left). On restore the target route — and its container — is
96
+ // still being committed by the view layer. The subscribe callback schedules a
97
+ // single rAF; for a heavy route (e.g. a long virtual list) the framework's
98
+ // commit can land AFTER that frame. Two distinct failures follow, each losing
99
+ // the saved position (Scenario 6 e2e, reproduced under CI's slower runner):
100
+ //
101
+ // 1. Container not mounted yet → `getContainer()` is `null`, the scroll
102
+ // silently falls back to `window`, which on a container-only route has
103
+ // nothing to scroll.
104
+ // 2. Container mounted but its content not laid out yet → `scrollHeight`
105
+ // is still small, so a single `scrollTo({ top })` clamps short of the
106
+ // saved position and never re-applies once layout grows.
107
+ //
108
+ // With no `scrollContainer` getter the target is always `window`, present
109
+ // from the first frame — restore in a single shot (unchanged behaviour). When
110
+ // a getter is configured we cannot tell "this route legitimately uses window"
111
+ // from "the container is still mounting", so re-apply the scroll on every
112
+ // frame for a bounded budget: window as a fallback while the container is
113
+ // absent (harmless clamp on container routes), the container itself once it
114
+ // appears. For instant restores we stop early the moment the position sticks;
115
+ // smooth restores animate asynchronously, so they run the full budget. The
116
+ // frame budget is the hard backstop against an unreachable target (saved
117
+ // position taller than the restored content).
118
+ const restorePos = (top) => {
119
+ if (!getContainer) {
120
+ globalThis.scrollTo({ top, left: 0, behavior });
121
+ return;
122
+ }
123
+ let frames = 0;
124
+ const attempt = () => {
125
+ if (destroyed) {
126
+ return;
127
+ }
128
+ const element = getContainer();
129
+ if (element) {
130
+ element.scrollTo({ top, left: 0, behavior });
131
+ // Instant restore landed within rounding tolerance → done; no point
132
+ // re-applying. Smooth restore never matches synchronously, so let it
133
+ // ride the budget.
134
+ if (behavior !== "smooth" && Math.abs(element.scrollTop - top) <= 1) {
135
+ return;
136
+ }
137
+ }
138
+ else {
139
+ globalThis.scrollTo({ top, left: 0, behavior });
140
+ }
141
+ if (frames >= RESTORE_RETRY_FRAMES) {
142
+ return;
143
+ }
144
+ frames += 1;
145
+ requestAnimationFrame(attempt);
146
+ };
147
+ attempt();
148
+ };
83
149
  const scrollToHashOrTop = (route) => {
84
150
  // URL plugin path (#532): `state.context.url.hash` is the source of truth
85
151
  // when one of the URL plugins (browser-plugin / navigation-plugin) is
@@ -155,24 +221,42 @@ export function createScrollRestoration(router, options) {
155
221
  putPos(prevKey, readPos());
156
222
  }
157
223
  }
158
- // Single rAF so DOM is committed before we read anchors / write scroll.
159
- // Guard against destroy() racing with the callback.
160
224
  requestAnimationFrame(() => {
161
225
  if (destroyed) {
162
226
  return;
163
227
  }
164
- if (mode === "top" || !nav) {
228
+ if (mode === "top") {
165
229
  scrollToHashOrTop(route);
166
230
  return;
167
231
  }
168
- if (nav.navigationType === "replace") {
232
+ // Restore branches (reload, back/traverse) MUST be evaluated before the
233
+ // replace-skip below. Since #657 lifted `replace` into TransitionMeta, a
234
+ // history TRAVERSAL (back/forward) under navigation-plugin carries
235
+ // `transition.replace === true` — a traversal reuses an existing history
236
+ // entry, which is replace-shaped at the history level. If the replace-skip
237
+ // ran first it would swallow every back/forward navigation and restore
238
+ // would never fire (the Scenario 6 e2e regression). Genuine in-place
239
+ // replaces (`router.navigate({ replace: true })`, navigateToNotFound) are
240
+ // not traversals and fall through to the skip below.
241
+ //
242
+ // Both arms of each check are required: `transition.reload` only fires for
243
+ // programmatic `router.navigate({reload:true})`. F5 under navigation-plugin
244
+ // primes `nav.navigationType === "reload"` via #531 getActivationType but
245
+ // leaves opts.reload undefined, so dropping the plugin arm would regress F5
246
+ // scroll-restore. Browser-plugin's F5 is not covered (no priming, out of
247
+ // scope).
248
+ if (route.transition.reload || nav?.navigationType === "reload") {
249
+ const key = safeKeyOf(route);
250
+ restorePos(key === null ? 0 : (loadStore()[key] ?? 0));
169
251
  return;
170
252
  }
171
- if (nav.direction === "back" ||
172
- nav.navigationType === "traverse" ||
173
- nav.navigationType === "reload") {
253
+ if (nav?.direction === "back" || nav?.navigationType === "traverse") {
174
254
  const key = safeKeyOf(route);
175
- writePos(key === null ? 0 : (loadStore()[key] ?? 0));
255
+ restorePos(key === null ? 0 : (loadStore()[key] ?? 0));
256
+ return;
257
+ }
258
+ // Genuine in-place replace (not a traversal) — leave scroll untouched.
259
+ if (route.transition.replace || nav?.navigationType === "replace") {
176
260
  return;
177
261
  }
178
262
  scrollToHashOrTop(route);
@@ -0,0 +1,64 @@
1
+ import type { Router } from "@real-router/core";
2
+ /**
3
+ * Router-coordinated scroll spy (#575).
4
+ *
5
+ * On `IntersectionObserver` notifications the utility picks the topmost
6
+ * visible anchor inside the configured scroll container and emits a forced
7
+ * same-route transition with `{ hash, replace: true, force: true, hashChange:
8
+ * true }` through `router.navigate(...)`. The URL plugin
9
+ * (`@real-router/browser-plugin` or `@real-router/navigation-plugin`) updates
10
+ * `state.context.url.hash` so sibling hash-aware `<Link hash>` re-highlights
11
+ * via the standard `createActiveRouteSource` pipeline.
12
+ *
13
+ * **Anti-flicker gates** (RFC §5.2):
14
+ * 1. `getTransitionSource(router).getSnapshot().isTransitioning` — skip emits
15
+ * while a transition is in-flight (re-entrant lock).
16
+ * 2. `coolingDown` — set on a user-driven hash transition (e.g. `<Link hash>`
17
+ * click + smooth `scrollIntoView`). Cleared on `scrollend` or after a
18
+ * 500ms safety timeout. Spy's own emits are excluded via the synchronous
19
+ * `selfEmitting` flag — required so the spy doesn't rate-limit itself.
20
+ *
21
+ * **Self-healing** (RFC §7.3): if the initial URL contains a hash without a
22
+ * matching `id` (e.g. `/page#nonexistent`), the first IO event emitted right
23
+ * after observe()-ing picks the topmost real anchor and corrects the URL.
24
+ *
25
+ * **Hash-only transition pipeline cost** (RFC §5.3): for same-route same-
26
+ * params hash-only navigations, `getTransitionPath` returns empty
27
+ * `toDeactivate` / `toActivate` arrays, so `runGuards` is a no-op. The only
28
+ * work is the URL plugin's `onTransitionSuccess` write and the
29
+ * `getTransitionSource` flip — cheap.
30
+ *
31
+ * **Architecture**: decomposed into 4 private subsystem closure factories
32
+ * (`createUrlPluginDetector`, `createCooldown`, `createDebouncer`,
33
+ * `createObserverPair`). The main `createScrollSpy` wires them together
34
+ * around the shared `silenced` / `destroyed` / `selfEmitting` flags and the
35
+ * `flush()` emit logic. Each subsystem owns its state + cleanup; `destroy()`
36
+ * delegates to each. See section banners below.
37
+ *
38
+ * @returns A `ScrollSpy` handle whose `destroy()` is idempotent.
39
+ */
40
+ export interface ScrollSpyOptions {
41
+ /**
42
+ * CSS selector for anchor candidates. Empty string `""` or `undefined`
43
+ * disables the spy (returns a NOOP handle). Common values:
44
+ * `"[id]"`, `"[id]:is(h1,h2,h3)"`, `"section[id]"`.
45
+ */
46
+ selector: string;
47
+ /**
48
+ * `IntersectionObserver` `rootMargin`. Default
49
+ * `"-20% 0px -60% 0px"` — an anchor is considered "active" once it crosses
50
+ * into the top 20 % of the viewport (or scroll container).
51
+ */
52
+ rootMargin?: string | undefined;
53
+ /**
54
+ * Lazy getter for the scrollable container. Resolved on every event.
55
+ * `null` (or missing getter) falls back to the window viewport
56
+ * (`root: null` on the `IntersectionObserver`).
57
+ */
58
+ scrollContainer?: (() => HTMLElement | null) | undefined;
59
+ }
60
+ export interface ScrollSpy {
61
+ /** Tear down observer + listeners. Idempotent. */
62
+ destroy: () => void;
63
+ }
64
+ export declare function createScrollSpy(router: Router, options: ScrollSpyOptions): ScrollSpy;
@@ -0,0 +1,418 @@
1
+ import { getTransitionSource } from "@real-router/sources";
2
+ const NOOP_INSTANCE = Object.freeze({
3
+ destroy: () => {
4
+ /* no-op */
5
+ },
6
+ });
7
+ // Hardcoded internals (RFC §5.1 — promote only with evidence).
8
+ const RAF_DEBOUNCE_MS = 150;
9
+ const MUTATION_DEBOUNCE_MS = 250;
10
+ const COOLDOWN_TIMEOUT_MS = 500;
11
+ const DEFAULT_ROOT_MARGIN = "-20% 0px -60% 0px";
12
+ const getUrlContext = (state) => state.context?.url;
13
+ // =============================================================================
14
+ // Picker — pure, no state. RFC §5.2 selection rule.
15
+ // =============================================================================
16
+ // Pick the anchor closest to the active zone top in viewport coordinates.
17
+ // `entry.rootBounds.top` already reflects `rootMargin` (per W3C IO spec
18
+ // §3.3) — for `rootMargin: "-20% 0px -60% 0px"` it returns 20% of root
19
+ // height, for `"-50% 0px -50% 0px"` it returns the center, etc. Distance
20
+ // = boundingClientRect.top − zoneTop in viewport pixels: positive = anchor
21
+ // below zone top (just entered), negative = anchor above zone top (body
22
+ // crossing zone from above). We prefer smallest non-negative; fall back to
23
+ // least-negative when no entry has crossed yet.
24
+ // Falls back to zoneTop = 0 when rootBounds is null (cross-origin roots,
25
+ // unit tests). Single pass — handles `Iterable` so flushes can pass
26
+ // `Map.values()` directly without realising the array.
27
+ const pickTopmost = (entries) => {
28
+ let bestPositive = null;
29
+ let bestPositiveDist = Number.POSITIVE_INFINITY;
30
+ let bestNegative = null;
31
+ let bestNegativeDist = Number.NEGATIVE_INFINITY;
32
+ for (const entry of entries) {
33
+ if (!entry.isIntersecting) {
34
+ continue;
35
+ }
36
+ const zoneTop = entry.rootBounds?.top ?? 0;
37
+ const distance = entry.boundingClientRect.top - zoneTop;
38
+ if (distance >= 0) {
39
+ if (distance < bestPositiveDist) {
40
+ bestPositive = entry;
41
+ bestPositiveDist = distance;
42
+ }
43
+ }
44
+ else if (distance > bestNegativeDist) {
45
+ bestNegative = entry;
46
+ bestNegativeDist = distance;
47
+ }
48
+ }
49
+ return bestPositive ?? bestNegative;
50
+ };
51
+ const createUrlPluginDetector = (router, onMissing) => {
52
+ let detectionUnsub = null;
53
+ const verify = (state) => {
54
+ const context = state.context;
55
+ if (context && context.url === undefined) {
56
+ console.warn("[real-router] scroll-spy: state.context.url is not claimed. " +
57
+ "Spy requires browser-plugin or navigation-plugin. Disabling.");
58
+ onMissing();
59
+ }
60
+ };
61
+ const peekState = router.getState();
62
+ if (peekState) {
63
+ verify(peekState);
64
+ }
65
+ else {
66
+ // Re-entry guard: `router.subscribe` MAY invoke the callback synchronously
67
+ // from inside `.subscribe(...)` before the function returns. In that case
68
+ // `detectionUnsub` is still `null` when the callback fires. Without this
69
+ // boolean, a hypothetical multi-fire would double-warn.
70
+ let detectionConsumed = false;
71
+ detectionUnsub = router.subscribe(({ route }) => {
72
+ if (detectionConsumed) {
73
+ return;
74
+ }
75
+ detectionConsumed = true;
76
+ verify(route);
77
+ detectionUnsub?.();
78
+ detectionUnsub = null;
79
+ });
80
+ }
81
+ return {
82
+ destroy() {
83
+ detectionUnsub?.();
84
+ detectionUnsub = null;
85
+ },
86
+ };
87
+ };
88
+ const createCooldown = (getContainer) => {
89
+ let active = false;
90
+ let timeout = null;
91
+ let listenerContainer = null;
92
+ let listener = null;
93
+ const clear = () => {
94
+ if (timeout !== null) {
95
+ clearTimeout(timeout);
96
+ timeout = null;
97
+ }
98
+ if (listener) {
99
+ const target = listenerContainer ?? globalThis;
100
+ target.removeEventListener("scrollend", listener);
101
+ }
102
+ listener = null;
103
+ listenerContainer = null;
104
+ active = false;
105
+ };
106
+ return {
107
+ get active() {
108
+ return active;
109
+ },
110
+ start() {
111
+ // Reset rather than stack timers if cooldown is already active.
112
+ clear();
113
+ active = true;
114
+ const lift = () => {
115
+ clear();
116
+ };
117
+ listener = lift;
118
+ listenerContainer = getContainer();
119
+ const target = listenerContainer ?? globalThis;
120
+ target.addEventListener("scrollend", lift, { once: true });
121
+ timeout = setTimeout(lift, COOLDOWN_TIMEOUT_MS);
122
+ },
123
+ destroy() {
124
+ clear();
125
+ },
126
+ };
127
+ };
128
+ const createDebouncer = (callback, trailingMs) => {
129
+ let raf = null;
130
+ let timeout = null;
131
+ return {
132
+ schedule() {
133
+ if (raf !== null) {
134
+ return;
135
+ }
136
+ raf = requestAnimationFrame(() => {
137
+ raf = null;
138
+ if (timeout !== null) {
139
+ clearTimeout(timeout);
140
+ }
141
+ timeout = setTimeout(() => {
142
+ timeout = null;
143
+ callback();
144
+ }, trailingMs);
145
+ });
146
+ },
147
+ destroy() {
148
+ if (raf !== null) {
149
+ cancelAnimationFrame(raf);
150
+ raf = null;
151
+ }
152
+ if (timeout !== null) {
153
+ clearTimeout(timeout);
154
+ timeout = null;
155
+ }
156
+ },
157
+ };
158
+ };
159
+ const createObserverPair = (selector, rootMargin, getContainer, onIntersection, onInvalidSelector, isStopped) => {
160
+ const observed = new Set();
161
+ // Latest IO entry per target — accumulated across batches. IO delivers
162
+ // entries only for targets whose intersection state CHANGED (W3C IO
163
+ // §3.2.1), so a fast scroll that lands two callbacks inside the same
164
+ // debounce window must merge by target, not overwrite. Entries are
165
+ // dropped from the map when their target leaves the DOM (see `reconcile`)
166
+ // and on `destroy()`.
167
+ const pending = new Map();
168
+ let duplicateIdWarned = false;
169
+ let mutationTimer = null;
170
+ const handleIntersection = (entries) => {
171
+ // Defensive: IO callback may fire AFTER `destroy()` if a queued event
172
+ // was already scheduled by the browser before `disconnect()`. Cheap
173
+ // belt-and-suspenders.
174
+ if (isStopped()) {
175
+ return;
176
+ }
177
+ for (const entry of entries) {
178
+ pending.set(entry.target, entry);
179
+ }
180
+ onIntersection();
181
+ };
182
+ const io = new IntersectionObserver(handleIntersection, {
183
+ root: getContainer(),
184
+ rootMargin,
185
+ threshold: 0,
186
+ });
187
+ const observeMatches = () => {
188
+ const scope = getContainer() ?? document;
189
+ let candidates;
190
+ try {
191
+ candidates = scope.querySelectorAll(selector);
192
+ }
193
+ catch {
194
+ onInvalidSelector();
195
+ return;
196
+ }
197
+ const seenIds = new Set();
198
+ for (const element of candidates) {
199
+ // Detect duplicate ids once (RFC §7.7). The DOM permits duplicate ids
200
+ // even though it is a markup bug; the spy keeps working but picks the
201
+ // first one deterministically via the topmost-visible rule.
202
+ const id = element.id;
203
+ if (id && !duplicateIdWarned) {
204
+ if (seenIds.has(id)) {
205
+ duplicateIdWarned = true;
206
+ console.warn(`[real-router] scroll-spy: duplicate id "${id}" observed. ` +
207
+ "Selection picks the topmost visible match deterministically.");
208
+ }
209
+ seenIds.add(id);
210
+ }
211
+ if (observed.has(element)) {
212
+ continue;
213
+ }
214
+ io.observe(element);
215
+ observed.add(element);
216
+ }
217
+ };
218
+ const reconcile = () => {
219
+ // Drop observed elements that left the DOM. Avoids observer holding
220
+ // strong refs to detached nodes. Also drop their accumulated entry so
221
+ // stale "was intersecting" state for a removed node cannot be picked
222
+ // by `pickTopmost` after the node is gone.
223
+ for (const element of observed) {
224
+ if (!element.isConnected) {
225
+ io.unobserve(element);
226
+ observed.delete(element);
227
+ pending.delete(element);
228
+ }
229
+ }
230
+ observeMatches();
231
+ };
232
+ observeMatches();
233
+ // MutationObserver targets the scroll container (or document.body for
234
+ // window viewport). `childList: true, subtree: true` catches structural
235
+ // changes; `attributes: true, attributeFilter: ["id"]` catches anchor
236
+ // id renames (typical for client-rendered docs).
237
+ const mutationTarget = getContainer() ?? document.body;
238
+ const mo = new MutationObserver(() => {
239
+ if (mutationTimer !== null) {
240
+ clearTimeout(mutationTimer);
241
+ }
242
+ mutationTimer = setTimeout(() => {
243
+ mutationTimer = null;
244
+ reconcile();
245
+ }, MUTATION_DEBOUNCE_MS);
246
+ });
247
+ mo.observe(mutationTarget, {
248
+ childList: true,
249
+ subtree: true,
250
+ attributes: true,
251
+ attributeFilter: ["id"],
252
+ });
253
+ return {
254
+ pending,
255
+ destroy() {
256
+ io.disconnect();
257
+ mo.disconnect();
258
+ if (mutationTimer !== null) {
259
+ clearTimeout(mutationTimer);
260
+ mutationTimer = null;
261
+ }
262
+ observed.clear();
263
+ pending.clear();
264
+ },
265
+ };
266
+ };
267
+ // =============================================================================
268
+ // Main: compositional wiring
269
+ // =============================================================================
270
+ export function createScrollSpy(router, options) {
271
+ // SSR guard (RFC §7.5) — return early without warnings.
272
+ if (typeof document === "undefined") {
273
+ return NOOP_INSTANCE;
274
+ }
275
+ // Feature-detect IntersectionObserver — no polyfill ships (RFC §4).
276
+ if (typeof IntersectionObserver === "undefined") {
277
+ return NOOP_INSTANCE;
278
+ }
279
+ const { selector } = options;
280
+ // Empty selector → disabled. Documented opt-out for conditional enabling
281
+ // (RFC §5.4 `scrollSpy={{ selector: enable ? "[id]" : "" }}`).
282
+ if (!selector) {
283
+ return NOOP_INSTANCE;
284
+ }
285
+ const rootMargin = options.rootMargin ?? DEFAULT_ROOT_MARGIN;
286
+ const getContainer = options.scrollContainer;
287
+ const resolveContainer = () => getContainer?.() ?? null;
288
+ // Shared lifecycle flags (Oracle Q1 — `silenced` has multiple unrelated
289
+ // triggers; Oracle Q3 — `selfEmitting` synchronously bracketed around
290
+ // `router.navigate()` cannot cleanly extract). Kept in main scope.
291
+ let destroyed = false;
292
+ let silenced = false;
293
+ let selfEmitting = false;
294
+ const isStopped = () => silenced || destroyed;
295
+ // Symmetric late-binding (Oracle Q2): declare `flush` as nullable, wire
296
+ // debouncer + observers, then assign the real implementation. Reads as
297
+ // intentional wiring rather than accidental closure capture ordering.
298
+ // The `flush?.()` call below safely no-ops if a callback somehow fires
299
+ // before assignment (impossible in practice — IO/debounce are async).
300
+ let flush = null;
301
+ const transitionSource = getTransitionSource(router);
302
+ const detector = createUrlPluginDetector(router, () => {
303
+ silenced = true;
304
+ });
305
+ const cooldown = createCooldown(resolveContainer);
306
+ const debouncer = createDebouncer(() => {
307
+ flush?.();
308
+ }, RAF_DEBOUNCE_MS);
309
+ const observers = createObserverPair(selector, rootMargin, resolveContainer, () => {
310
+ debouncer.schedule();
311
+ }, () => {
312
+ if (silenced) {
313
+ return;
314
+ }
315
+ silenced = true;
316
+ console.warn(`[real-router] scroll-spy: invalid selector "${selector}". Disabling.`);
317
+ }, isStopped);
318
+ flush = () => {
319
+ if (destroyed || silenced) {
320
+ observers.pending.clear();
321
+ return;
322
+ }
323
+ // Gate-skipped flushes keep `pendingEntries` populated — the merged
324
+ // state is still the best-known snapshot, and the next non-gated flush
325
+ // consumes it. Clearing under a gate would re-introduce the overwrite
326
+ // bug for any anchor whose intersection state did not change during
327
+ // the gate window.
328
+ if (transitionSource.getSnapshot().isTransitioning) {
329
+ return;
330
+ }
331
+ if (cooldown.active) {
332
+ return;
333
+ }
334
+ if (observers.pending.size === 0) {
335
+ return;
336
+ }
337
+ // Successful flush consumes the merged snapshot. We clear so that the
338
+ // next debounce window starts fresh; an anchor that is still
339
+ // intersecting will only stay observable if IO emits another event for
340
+ // it (which it does whenever the anchor's intersection state actually
341
+ // changes). Skipping the clear here would leak state from one user-
342
+ // perceived "scroll stop" into the next.
343
+ const picked = pickTopmost(observers.pending.values());
344
+ observers.pending.clear();
345
+ if (!picked) {
346
+ // No anchor visible / above zone — preserve last hash (RFC §10 #5).
347
+ return;
348
+ }
349
+ const newHash = picked.target.id;
350
+ if (!newHash) {
351
+ return;
352
+ }
353
+ const state = router.getState();
354
+ if (!state) {
355
+ return;
356
+ }
357
+ const currentHash = getUrlContext(state)?.hash ?? "";
358
+ if (newHash === currentHash) {
359
+ return;
360
+ }
361
+ // Emit the same-route same-params hash-only transition. URL plugin
362
+ // writes `state.context.url.hash = newHash` + `hashChanged = true` in
363
+ // its `onTransitionSuccess` claim.
364
+ const opts = {
365
+ hash: newHash,
366
+ replace: true,
367
+ force: true,
368
+ hashChange: true,
369
+ };
370
+ // Self-emit guard (RFC §5.2): set synchronously around our own
371
+ // `router.navigate()` so the `router.subscribe` callback skips the
372
+ // cooldown setup for spy-emitted transitions — otherwise spy would
373
+ // rate-limit itself to ≤ 2 emits/s, contradicting the ≤ 10/s benchmark
374
+ // target. Test coupling (Q8): preserve exact `.catch(noop).finally(reset)`
375
+ // chain — migrating to `try/finally` over `await router.navigate(...)`
376
+ // changes microtask schedule and breaks "spy continues after rejection".
377
+ selfEmitting = true;
378
+ router
379
+ .navigate(state.name, state.params, opts)
380
+ .catch(() => {
381
+ // Fire-and-forget — suppress expected rejections (concurrent
382
+ // navigate, router stopped, etc.) consistent with `<Link>` adapter
383
+ // patterns.
384
+ })
385
+ .finally(() => {
386
+ selfEmitting = false;
387
+ });
388
+ };
389
+ // Cooldown setup on user-driven hash transitions. Spy's own emits are
390
+ // distinguished via the synchronous `selfEmitting` flag (see `flush`).
391
+ const unsubscribeRouter = router.subscribe(({ route }) => {
392
+ if (selfEmitting) {
393
+ return;
394
+ }
395
+ if (getUrlContext(route)?.hashChanged) {
396
+ cooldown.start();
397
+ }
398
+ });
399
+ return {
400
+ destroy() {
401
+ if (destroyed) {
402
+ return;
403
+ }
404
+ destroyed = true;
405
+ // Unsubscribe FIRST to prevent late-arriving router transition
406
+ // callback from calling `cooldown.start()` on a half-destroyed
407
+ // instance. Without this ordering, a transition with `hashChanged:
408
+ // true` firing between subsystem teardown and `unsubscribeRouter()`
409
+ // would re-install a 500ms timer that survives `destroy()`. Verified
410
+ // via Oracle review (Q5/Q7).
411
+ unsubscribeRouter();
412
+ observers.destroy();
413
+ debouncer.destroy();
414
+ cooldown.destroy();
415
+ detector.destroy();
416
+ },
417
+ };
418
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-router/svelte",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Svelte 5 integration for Real-Router",
6
6
  "svelte": "./dist/index.js",
@@ -49,9 +49,9 @@
49
49
  "license": "MIT",
50
50
  "sideEffects": false,
51
51
  "dependencies": {
52
- "@real-router/core": "^0.53.0",
52
+ "@real-router/core": "^0.54.6",
53
53
  "@real-router/route-utils": "^0.2.2",
54
- "@real-router/sources": "^0.8.2"
54
+ "@real-router/sources": "^0.8.3"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@sveltejs/package": "2.5.7",
@@ -63,7 +63,7 @@
63
63
  "svelte": "5.55.7",
64
64
  "svelte-check": "4.4.5",
65
65
  "svelte-eslint-parser": "1.6.0",
66
- "@real-router/browser-plugin": "^0.17.2"
66
+ "@real-router/browser-plugin": "^0.17.4"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "svelte": ">=5.7.0"
@@ -4,6 +4,7 @@
4
4
  import {
5
5
  createRouteAnnouncer,
6
6
  createScrollRestoration,
7
+ createScrollSpy,
7
8
  createViewTransitions,
8
9
  } from "./dom-utils";
9
10
  import { setContext, untrack } from "svelte";
@@ -12,7 +13,7 @@
12
13
  import { createRouteContext } from "./createRouteContext.svelte";
13
14
  import { NAVIGATOR_KEY, ROUTE_KEY, ROUTER_KEY } from "./context";
14
15
 
15
- import type { ScrollRestorationOptions } from "./dom-utils";
16
+ import type { ScrollRestorationOptions, ScrollSpyOptions } from "./dom-utils";
16
17
  import type { Router } from "@real-router/core";
17
18
  import type { Snippet } from "svelte";
18
19
 
@@ -21,13 +22,15 @@
21
22
  children,
22
23
  announceNavigation,
23
24
  scrollRestoration,
25
+ scrollSpy,
24
26
  viewTransitions,
25
27
  }: {
26
28
  router: Router;
27
29
  children: Snippet;
28
- announceNavigation?: boolean;
29
- scrollRestoration?: ScrollRestorationOptions;
30
- viewTransitions?: boolean;
30
+ announceNavigation?: boolean | undefined;
31
+ scrollRestoration?: ScrollRestorationOptions | undefined;
32
+ scrollSpy?: ScrollSpyOptions | undefined;
33
+ viewTransitions?: boolean | undefined;
31
34
  } = $props();
32
35
 
33
36
  $effect(() => {
@@ -73,6 +76,23 @@
73
76
  return () => sr.destroy();
74
77
  });
75
78
 
79
+ const spyEnabled = $derived(
80
+ scrollSpy !== undefined && scrollSpy.selector !== "",
81
+ );
82
+ const spySelector = $derived(scrollSpy?.selector);
83
+ const spyRootMargin = $derived(scrollSpy?.rootMargin);
84
+
85
+ $effect(() => {
86
+ if (!spyEnabled || !spySelector) return;
87
+ void spyRootMargin;
88
+ const spy = createScrollSpy(router, {
89
+ selector: spySelector,
90
+ rootMargin: spyRootMargin,
91
+ scrollContainer: untrack(() => scrollSpy?.scrollContainer),
92
+ });
93
+ return () => spy.destroy();
94
+ });
95
+
76
96
  $effect(() => {
77
97
  if (!viewTransitions) return;
78
98
  const vt = createViewTransitions(router);
package/src/constants.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  import type { NavigationOptions, Params } from "@real-router/core";
2
2
 
3
- export const EMPTY_PARAMS: Params = Object.freeze({}) as Params;
3
+ export const EMPTY_PARAMS: Params = Object.freeze({});
4
4
 
5
- export const EMPTY_OPTIONS: NavigationOptions = Object.freeze(
6
- {},
7
- ) as NavigationOptions;
5
+ export const EMPTY_OPTIONS: NavigationOptions = Object.freeze({});
8
6
 
9
7
  export const NOOP = (): void => {};