@real-router/angular 0.10.0 → 0.11.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 +23 -2
- package/dist/README.md +23 -2
- package/dist/fesm2022/real-router-angular.mjs +533 -20
- package/dist/fesm2022/real-router-angular.mjs.map +1 -1
- package/dist/types/real-router-angular.d.ts +62 -0
- package/dist/types/real-router-angular.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/dom-utils/index.ts +4 -0
- package/src/dom-utils/scroll-restore.ts +99 -12
- package/src/dom-utils/scroll-spy.ts +688 -0
- package/src/internal/install.ts +15 -2
- package/src/providers.ts +13 -1
- package/src/providersFactory.ts +21 -3
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import { getTransitionSource } from "@real-router/sources";
|
|
2
|
+
|
|
3
|
+
import type { NavigationOptions, Router } from "@real-router/core";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Router-coordinated scroll spy (#575).
|
|
7
|
+
*
|
|
8
|
+
* On `IntersectionObserver` notifications the utility picks the topmost
|
|
9
|
+
* visible anchor inside the configured scroll container and emits a forced
|
|
10
|
+
* same-route transition with `{ hash, replace: true, force: true, hashChange:
|
|
11
|
+
* true }` through `router.navigate(...)`. The URL plugin
|
|
12
|
+
* (`@real-router/browser-plugin` or `@real-router/navigation-plugin`) updates
|
|
13
|
+
* `state.context.url.hash` so sibling hash-aware `<Link hash>` re-highlights
|
|
14
|
+
* via the standard `createActiveRouteSource` pipeline.
|
|
15
|
+
*
|
|
16
|
+
* **Anti-flicker gates** (RFC §5.2):
|
|
17
|
+
* 1. `getTransitionSource(router).getSnapshot().isTransitioning` — skip emits
|
|
18
|
+
* while a transition is in-flight (re-entrant lock).
|
|
19
|
+
* 2. `coolingDown` — set on a user-driven hash transition (e.g. `<Link hash>`
|
|
20
|
+
* click + smooth `scrollIntoView`). Cleared on `scrollend` or after a
|
|
21
|
+
* 500ms safety timeout. Spy's own emits are excluded via the synchronous
|
|
22
|
+
* `selfEmitting` flag — required so the spy doesn't rate-limit itself.
|
|
23
|
+
*
|
|
24
|
+
* **Self-healing** (RFC §7.3): if the initial URL contains a hash without a
|
|
25
|
+
* matching `id` (e.g. `/page#nonexistent`), the first IO event emitted right
|
|
26
|
+
* after observe()-ing picks the topmost real anchor and corrects the URL.
|
|
27
|
+
*
|
|
28
|
+
* **Hash-only transition pipeline cost** (RFC §5.3): for same-route same-
|
|
29
|
+
* params hash-only navigations, `getTransitionPath` returns empty
|
|
30
|
+
* `toDeactivate` / `toActivate` arrays, so `runGuards` is a no-op. The only
|
|
31
|
+
* work is the URL plugin's `onTransitionSuccess` write and the
|
|
32
|
+
* `getTransitionSource` flip — cheap.
|
|
33
|
+
*
|
|
34
|
+
* **Architecture**: decomposed into 4 private subsystem closure factories
|
|
35
|
+
* (`createUrlPluginDetector`, `createCooldown`, `createDebouncer`,
|
|
36
|
+
* `createObserverPair`). The main `createScrollSpy` wires them together
|
|
37
|
+
* around the shared `silenced` / `destroyed` / `selfEmitting` flags and the
|
|
38
|
+
* `flush()` emit logic. Each subsystem owns its state + cleanup; `destroy()`
|
|
39
|
+
* delegates to each. See section banners below.
|
|
40
|
+
*
|
|
41
|
+
* @returns A `ScrollSpy` handle whose `destroy()` is idempotent.
|
|
42
|
+
*/
|
|
43
|
+
export interface ScrollSpyOptions {
|
|
44
|
+
/**
|
|
45
|
+
* CSS selector for anchor candidates. Empty string `""` or `undefined`
|
|
46
|
+
* disables the spy (returns a NOOP handle). Common values:
|
|
47
|
+
* `"[id]"`, `"[id]:is(h1,h2,h3)"`, `"section[id]"`.
|
|
48
|
+
*/
|
|
49
|
+
selector: string;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `IntersectionObserver` `rootMargin`. Default
|
|
53
|
+
* `"-20% 0px -60% 0px"` — an anchor is considered "active" once it crosses
|
|
54
|
+
* into the top 20 % of the viewport (or scroll container).
|
|
55
|
+
*/
|
|
56
|
+
rootMargin?: string | undefined;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Lazy getter for the scrollable container. Resolved on every event.
|
|
60
|
+
* `null` (or missing getter) falls back to the window viewport
|
|
61
|
+
* (`root: null` on the `IntersectionObserver`).
|
|
62
|
+
*/
|
|
63
|
+
scrollContainer?: (() => HTMLElement | null) | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ScrollSpy {
|
|
67
|
+
/** Tear down observer + listeners. Idempotent. */
|
|
68
|
+
destroy: () => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const NOOP_INSTANCE: ScrollSpy = Object.freeze({
|
|
72
|
+
destroy: () => {
|
|
73
|
+
/* no-op */
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Hardcoded internals (RFC §5.1 — promote only with evidence).
|
|
78
|
+
const RAF_DEBOUNCE_MS = 150;
|
|
79
|
+
const MUTATION_DEBOUNCE_MS = 250;
|
|
80
|
+
const COOLDOWN_TIMEOUT_MS = 500;
|
|
81
|
+
const DEFAULT_ROOT_MARGIN = "-20% 0px -60% 0px";
|
|
82
|
+
|
|
83
|
+
// Local extension type — browser-plugin / navigation-plugin augment
|
|
84
|
+
// `NavigationOptions` with `hash` and `hashChange`, but `shared/dom-utils`
|
|
85
|
+
// is plugin-agnostic and cannot rely on the augmentation. Mirrors the
|
|
86
|
+
// `HashAwareNavigationOptions` pattern in `link-utils.ts`.
|
|
87
|
+
type HashAwareNavigationOptions = NavigationOptions & {
|
|
88
|
+
hash?: string;
|
|
89
|
+
hashChange?: boolean;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
interface UrlContextSlice {
|
|
93
|
+
hash?: string;
|
|
94
|
+
hashChanged?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const getUrlContext = (state: {
|
|
98
|
+
context?: unknown;
|
|
99
|
+
}): UrlContextSlice | undefined =>
|
|
100
|
+
(state.context as { url?: UrlContextSlice } | undefined)?.url;
|
|
101
|
+
|
|
102
|
+
// =============================================================================
|
|
103
|
+
// Picker — pure, no state. RFC §5.2 selection rule.
|
|
104
|
+
// =============================================================================
|
|
105
|
+
|
|
106
|
+
// Pick the anchor closest to the active zone top in viewport coordinates.
|
|
107
|
+
// `entry.rootBounds.top` already reflects `rootMargin` (per W3C IO spec
|
|
108
|
+
// §3.3) — for `rootMargin: "-20% 0px -60% 0px"` it returns 20% of root
|
|
109
|
+
// height, for `"-50% 0px -50% 0px"` it returns the center, etc. Distance
|
|
110
|
+
// = boundingClientRect.top − zoneTop in viewport pixels: positive = anchor
|
|
111
|
+
// below zone top (just entered), negative = anchor above zone top (body
|
|
112
|
+
// crossing zone from above). We prefer smallest non-negative; fall back to
|
|
113
|
+
// least-negative when no entry has crossed yet.
|
|
114
|
+
// Falls back to zoneTop = 0 when rootBounds is null (cross-origin roots,
|
|
115
|
+
// unit tests). Single pass — handles `Iterable` so flushes can pass
|
|
116
|
+
// `Map.values()` directly without realising the array.
|
|
117
|
+
const pickTopmost = (
|
|
118
|
+
entries: Iterable<IntersectionObserverEntry>,
|
|
119
|
+
): IntersectionObserverEntry | null => {
|
|
120
|
+
let bestPositive: IntersectionObserverEntry | null = null;
|
|
121
|
+
let bestPositiveDist = Number.POSITIVE_INFINITY;
|
|
122
|
+
let bestNegative: IntersectionObserverEntry | null = null;
|
|
123
|
+
let bestNegativeDist = Number.NEGATIVE_INFINITY;
|
|
124
|
+
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
if (!entry.isIntersecting) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const zoneTop = entry.rootBounds?.top ?? 0;
|
|
131
|
+
const distance = entry.boundingClientRect.top - zoneTop;
|
|
132
|
+
|
|
133
|
+
if (distance >= 0) {
|
|
134
|
+
if (distance < bestPositiveDist) {
|
|
135
|
+
bestPositive = entry;
|
|
136
|
+
bestPositiveDist = distance;
|
|
137
|
+
}
|
|
138
|
+
} else if (distance > bestNegativeDist) {
|
|
139
|
+
bestNegative = entry;
|
|
140
|
+
bestNegativeDist = distance;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return bestPositive ?? bestNegative;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// =============================================================================
|
|
148
|
+
// Subsystem: URL plugin detector (RFC §5.5)
|
|
149
|
+
// Calls `onMissing` if `state.context` is published but `url` key is missing
|
|
150
|
+
// (i.e. no URL plugin installed). Either synchronous on start, or deferred
|
|
151
|
+
// via a one-shot `router.subscribe` if the router has not started yet.
|
|
152
|
+
// `silenced` flag itself lives in main scope — detector signals via callback
|
|
153
|
+
// (per Oracle Q1 — `silenced` has multiple unrelated triggers; main scope
|
|
154
|
+
// owns the kill switch).
|
|
155
|
+
// =============================================================================
|
|
156
|
+
|
|
157
|
+
interface UrlPluginDetector {
|
|
158
|
+
destroy: () => void;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const createUrlPluginDetector = (
|
|
162
|
+
router: Router,
|
|
163
|
+
onMissing: () => void,
|
|
164
|
+
): UrlPluginDetector => {
|
|
165
|
+
let detectionUnsub: (() => void) | null = null;
|
|
166
|
+
|
|
167
|
+
const verify = (state: { context?: unknown }): void => {
|
|
168
|
+
const context = state.context as
|
|
169
|
+
| (Record<string, unknown> & { url?: unknown })
|
|
170
|
+
| undefined;
|
|
171
|
+
|
|
172
|
+
if (context && context.url === undefined) {
|
|
173
|
+
console.warn(
|
|
174
|
+
"[real-router] scroll-spy: state.context.url is not claimed. " +
|
|
175
|
+
"Spy requires browser-plugin or navigation-plugin. Disabling.",
|
|
176
|
+
);
|
|
177
|
+
onMissing();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const peekState = router.getState();
|
|
182
|
+
|
|
183
|
+
if (peekState) {
|
|
184
|
+
verify(peekState);
|
|
185
|
+
} else {
|
|
186
|
+
// Re-entry guard: `router.subscribe` MAY invoke the callback synchronously
|
|
187
|
+
// from inside `.subscribe(...)` before the function returns. In that case
|
|
188
|
+
// `detectionUnsub` is still `null` when the callback fires. Without this
|
|
189
|
+
// boolean, a hypothetical multi-fire would double-warn.
|
|
190
|
+
let detectionConsumed = false;
|
|
191
|
+
|
|
192
|
+
detectionUnsub = router.subscribe(({ route }) => {
|
|
193
|
+
if (detectionConsumed) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
detectionConsumed = true;
|
|
198
|
+
verify(route);
|
|
199
|
+
|
|
200
|
+
detectionUnsub?.();
|
|
201
|
+
detectionUnsub = null;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
destroy(): void {
|
|
207
|
+
detectionUnsub?.();
|
|
208
|
+
detectionUnsub = null;
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// =============================================================================
|
|
214
|
+
// Subsystem: Cooldown gate (RFC §5.2 — anti-flicker for smooth scrollIntoView)
|
|
215
|
+
// Set on user-driven `<Link hash>` click → smooth scroll. Cleared on
|
|
216
|
+
// `scrollend` (Baseline 2026) or 500ms safety timeout (older Safari).
|
|
217
|
+
// =============================================================================
|
|
218
|
+
|
|
219
|
+
interface Cooldown {
|
|
220
|
+
readonly active: boolean;
|
|
221
|
+
start: () => void;
|
|
222
|
+
destroy: () => void;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const createCooldown = (getContainer: () => HTMLElement | null): Cooldown => {
|
|
226
|
+
let active = false;
|
|
227
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
228
|
+
let listenerContainer: HTMLElement | null = null;
|
|
229
|
+
let listener: (() => void) | null = null;
|
|
230
|
+
|
|
231
|
+
const clear = (): void => {
|
|
232
|
+
if (timeout !== null) {
|
|
233
|
+
clearTimeout(timeout);
|
|
234
|
+
timeout = null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (listener) {
|
|
238
|
+
const target: EventTarget = listenerContainer ?? globalThis;
|
|
239
|
+
|
|
240
|
+
target.removeEventListener("scrollend", listener);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
listener = null;
|
|
244
|
+
listenerContainer = null;
|
|
245
|
+
active = false;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
get active(): boolean {
|
|
250
|
+
return active;
|
|
251
|
+
},
|
|
252
|
+
start(): void {
|
|
253
|
+
// Reset rather than stack timers if cooldown is already active.
|
|
254
|
+
clear();
|
|
255
|
+
|
|
256
|
+
active = true;
|
|
257
|
+
|
|
258
|
+
const lift = (): void => {
|
|
259
|
+
clear();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
listener = lift;
|
|
263
|
+
listenerContainer = getContainer();
|
|
264
|
+
|
|
265
|
+
const target: EventTarget = listenerContainer ?? globalThis;
|
|
266
|
+
|
|
267
|
+
target.addEventListener("scrollend", lift, { once: true });
|
|
268
|
+
|
|
269
|
+
timeout = setTimeout(lift, COOLDOWN_TIMEOUT_MS);
|
|
270
|
+
},
|
|
271
|
+
destroy(): void {
|
|
272
|
+
clear();
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// =============================================================================
|
|
278
|
+
// Subsystem: rAF + trailing debounce (RFC §5.1)
|
|
279
|
+
// Coalesces a burst of IO events into ≤ 1 callback per debounce window.
|
|
280
|
+
// rAF reduces N setTimeout creations to 1 per animation frame; the trailing
|
|
281
|
+
// 150ms setTimeout waits for the IO stream to quiesce.
|
|
282
|
+
// =============================================================================
|
|
283
|
+
|
|
284
|
+
interface Debouncer {
|
|
285
|
+
schedule: () => void;
|
|
286
|
+
destroy: () => void;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const createDebouncer = (
|
|
290
|
+
callback: () => void,
|
|
291
|
+
trailingMs: number,
|
|
292
|
+
): Debouncer => {
|
|
293
|
+
let raf: number | null = null;
|
|
294
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
schedule(): void {
|
|
298
|
+
if (raf !== null) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
raf = requestAnimationFrame(() => {
|
|
303
|
+
raf = null;
|
|
304
|
+
|
|
305
|
+
if (timeout !== null) {
|
|
306
|
+
clearTimeout(timeout);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
timeout = setTimeout(() => {
|
|
310
|
+
timeout = null;
|
|
311
|
+
callback();
|
|
312
|
+
}, trailingMs);
|
|
313
|
+
});
|
|
314
|
+
},
|
|
315
|
+
destroy(): void {
|
|
316
|
+
if (raf !== null) {
|
|
317
|
+
cancelAnimationFrame(raf);
|
|
318
|
+
raf = null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (timeout !== null) {
|
|
322
|
+
clearTimeout(timeout);
|
|
323
|
+
timeout = null;
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// =============================================================================
|
|
330
|
+
// Subsystem: Observer pair (IntersectionObserver + MutationObserver)
|
|
331
|
+
// IO + MO genuinely form one subsystem — both write/read `observed` set and
|
|
332
|
+
// `pending` map, and reconcile flow couples them. Per Oracle Q10, splitting
|
|
333
|
+
// would force cross-subsystem references that re-introduce the wiring
|
|
334
|
+
// problem we're trying to solve.
|
|
335
|
+
//
|
|
336
|
+
// Exposes `pending` directly (per Oracle Q4: hiding behind `consume()` adds
|
|
337
|
+
// boilerplate without isolating the shared mutable state — observers write
|
|
338
|
+
// from IO callbacks while main scope reads in `flush()`).
|
|
339
|
+
// =============================================================================
|
|
340
|
+
|
|
341
|
+
interface ObserverPair {
|
|
342
|
+
readonly pending: Map<Element, IntersectionObserverEntry>;
|
|
343
|
+
destroy: () => void;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const createObserverPair = (
|
|
347
|
+
selector: string,
|
|
348
|
+
rootMargin: string,
|
|
349
|
+
getContainer: () => HTMLElement | null,
|
|
350
|
+
onIntersection: () => void,
|
|
351
|
+
onInvalidSelector: () => void,
|
|
352
|
+
isStopped: () => boolean,
|
|
353
|
+
): ObserverPair => {
|
|
354
|
+
const observed = new Set<Element>();
|
|
355
|
+
// Latest IO entry per target — accumulated across batches. IO delivers
|
|
356
|
+
// entries only for targets whose intersection state CHANGED (W3C IO
|
|
357
|
+
// §3.2.1), so a fast scroll that lands two callbacks inside the same
|
|
358
|
+
// debounce window must merge by target, not overwrite. Entries are
|
|
359
|
+
// dropped from the map when their target leaves the DOM (see `reconcile`)
|
|
360
|
+
// and on `destroy()`.
|
|
361
|
+
const pending = new Map<Element, IntersectionObserverEntry>();
|
|
362
|
+
|
|
363
|
+
let duplicateIdWarned = false;
|
|
364
|
+
let mutationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
365
|
+
|
|
366
|
+
const handleIntersection: IntersectionObserverCallback = (entries) => {
|
|
367
|
+
// Defensive: IO callback may fire AFTER `destroy()` if a queued event
|
|
368
|
+
// was already scheduled by the browser before `disconnect()`. Cheap
|
|
369
|
+
// belt-and-suspenders.
|
|
370
|
+
if (isStopped()) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const entry of entries) {
|
|
375
|
+
pending.set(entry.target, entry);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
onIntersection();
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const io = new IntersectionObserver(handleIntersection, {
|
|
382
|
+
root: getContainer(),
|
|
383
|
+
rootMargin,
|
|
384
|
+
threshold: 0,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const observeMatches = (): void => {
|
|
388
|
+
const scope = getContainer() ?? document;
|
|
389
|
+
let candidates: NodeListOf<Element>;
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
candidates = scope.querySelectorAll(selector);
|
|
393
|
+
} catch {
|
|
394
|
+
onInvalidSelector();
|
|
395
|
+
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const seenIds = new Set<string>();
|
|
400
|
+
|
|
401
|
+
for (const element of candidates) {
|
|
402
|
+
// Detect duplicate ids once (RFC §7.7). The DOM permits duplicate ids
|
|
403
|
+
// even though it is a markup bug; the spy keeps working but picks the
|
|
404
|
+
// first one deterministically via the topmost-visible rule.
|
|
405
|
+
const id = (element as HTMLElement).id;
|
|
406
|
+
|
|
407
|
+
if (id && !duplicateIdWarned) {
|
|
408
|
+
if (seenIds.has(id)) {
|
|
409
|
+
duplicateIdWarned = true;
|
|
410
|
+
|
|
411
|
+
console.warn(
|
|
412
|
+
`[real-router] scroll-spy: duplicate id "${id}" observed. ` +
|
|
413
|
+
"Selection picks the topmost visible match deterministically.",
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
seenIds.add(id);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (observed.has(element)) {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
io.observe(element);
|
|
425
|
+
observed.add(element);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const reconcile = (): void => {
|
|
430
|
+
// Drop observed elements that left the DOM. Avoids observer holding
|
|
431
|
+
// strong refs to detached nodes. Also drop their accumulated entry so
|
|
432
|
+
// stale "was intersecting" state for a removed node cannot be picked
|
|
433
|
+
// by `pickTopmost` after the node is gone.
|
|
434
|
+
for (const element of observed) {
|
|
435
|
+
if (!element.isConnected) {
|
|
436
|
+
io.unobserve(element);
|
|
437
|
+
observed.delete(element);
|
|
438
|
+
pending.delete(element);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
observeMatches();
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
observeMatches();
|
|
446
|
+
|
|
447
|
+
// MutationObserver targets the scroll container (or document.body for
|
|
448
|
+
// window viewport). `childList: true, subtree: true` catches structural
|
|
449
|
+
// changes; `attributes: true, attributeFilter: ["id"]` catches anchor
|
|
450
|
+
// id renames (typical for client-rendered docs).
|
|
451
|
+
const mutationTarget = getContainer() ?? document.body;
|
|
452
|
+
|
|
453
|
+
const mo = new MutationObserver(() => {
|
|
454
|
+
if (mutationTimer !== null) {
|
|
455
|
+
clearTimeout(mutationTimer);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
mutationTimer = setTimeout(() => {
|
|
459
|
+
mutationTimer = null;
|
|
460
|
+
reconcile();
|
|
461
|
+
}, MUTATION_DEBOUNCE_MS);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
mo.observe(mutationTarget, {
|
|
465
|
+
childList: true,
|
|
466
|
+
subtree: true,
|
|
467
|
+
attributes: true,
|
|
468
|
+
attributeFilter: ["id"],
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
pending,
|
|
473
|
+
destroy(): void {
|
|
474
|
+
io.disconnect();
|
|
475
|
+
mo.disconnect();
|
|
476
|
+
|
|
477
|
+
if (mutationTimer !== null) {
|
|
478
|
+
clearTimeout(mutationTimer);
|
|
479
|
+
mutationTimer = null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
observed.clear();
|
|
483
|
+
pending.clear();
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// =============================================================================
|
|
489
|
+
// Main: compositional wiring
|
|
490
|
+
// =============================================================================
|
|
491
|
+
|
|
492
|
+
export function createScrollSpy(
|
|
493
|
+
router: Router,
|
|
494
|
+
options: ScrollSpyOptions,
|
|
495
|
+
): ScrollSpy {
|
|
496
|
+
// SSR guard (RFC §7.5) — return early without warnings.
|
|
497
|
+
if (typeof document === "undefined") {
|
|
498
|
+
return NOOP_INSTANCE;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Feature-detect IntersectionObserver — no polyfill ships (RFC §4).
|
|
502
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
503
|
+
return NOOP_INSTANCE;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const { selector } = options;
|
|
507
|
+
|
|
508
|
+
// Empty selector → disabled. Documented opt-out for conditional enabling
|
|
509
|
+
// (RFC §5.4 `scrollSpy={{ selector: enable ? "[id]" : "" }}`).
|
|
510
|
+
if (!selector) {
|
|
511
|
+
return NOOP_INSTANCE;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const rootMargin = options.rootMargin ?? DEFAULT_ROOT_MARGIN;
|
|
515
|
+
const getContainer = options.scrollContainer;
|
|
516
|
+
const resolveContainer = (): HTMLElement | null => getContainer?.() ?? null;
|
|
517
|
+
|
|
518
|
+
// Shared lifecycle flags (Oracle Q1 — `silenced` has multiple unrelated
|
|
519
|
+
// triggers; Oracle Q3 — `selfEmitting` synchronously bracketed around
|
|
520
|
+
// `router.navigate()` cannot cleanly extract). Kept in main scope.
|
|
521
|
+
let destroyed = false;
|
|
522
|
+
let silenced = false;
|
|
523
|
+
let selfEmitting = false;
|
|
524
|
+
|
|
525
|
+
const isStopped = (): boolean => silenced || destroyed;
|
|
526
|
+
|
|
527
|
+
// Symmetric late-binding (Oracle Q2): declare `flush` as nullable, wire
|
|
528
|
+
// debouncer + observers, then assign the real implementation. Reads as
|
|
529
|
+
// intentional wiring rather than accidental closure capture ordering.
|
|
530
|
+
// The `flush?.()` call below safely no-ops if a callback somehow fires
|
|
531
|
+
// before assignment (impossible in practice — IO/debounce are async).
|
|
532
|
+
let flush: (() => void) | null = null;
|
|
533
|
+
|
|
534
|
+
const transitionSource = getTransitionSource(router);
|
|
535
|
+
|
|
536
|
+
const detector = createUrlPluginDetector(router, () => {
|
|
537
|
+
silenced = true;
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const cooldown = createCooldown(resolveContainer);
|
|
541
|
+
|
|
542
|
+
const debouncer = createDebouncer(() => {
|
|
543
|
+
flush?.();
|
|
544
|
+
}, RAF_DEBOUNCE_MS);
|
|
545
|
+
|
|
546
|
+
const observers = createObserverPair(
|
|
547
|
+
selector,
|
|
548
|
+
rootMargin,
|
|
549
|
+
resolveContainer,
|
|
550
|
+
() => {
|
|
551
|
+
debouncer.schedule();
|
|
552
|
+
},
|
|
553
|
+
() => {
|
|
554
|
+
if (silenced) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
silenced = true;
|
|
559
|
+
|
|
560
|
+
console.warn(
|
|
561
|
+
`[real-router] scroll-spy: invalid selector "${selector}". Disabling.`,
|
|
562
|
+
);
|
|
563
|
+
},
|
|
564
|
+
isStopped,
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
flush = (): void => {
|
|
568
|
+
if (destroyed || silenced) {
|
|
569
|
+
observers.pending.clear();
|
|
570
|
+
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Gate-skipped flushes keep `pendingEntries` populated — the merged
|
|
575
|
+
// state is still the best-known snapshot, and the next non-gated flush
|
|
576
|
+
// consumes it. Clearing under a gate would re-introduce the overwrite
|
|
577
|
+
// bug for any anchor whose intersection state did not change during
|
|
578
|
+
// the gate window.
|
|
579
|
+
if (transitionSource.getSnapshot().isTransitioning) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (cooldown.active) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (observers.pending.size === 0) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Successful flush consumes the merged snapshot. We clear so that the
|
|
592
|
+
// next debounce window starts fresh; an anchor that is still
|
|
593
|
+
// intersecting will only stay observable if IO emits another event for
|
|
594
|
+
// it (which it does whenever the anchor's intersection state actually
|
|
595
|
+
// changes). Skipping the clear here would leak state from one user-
|
|
596
|
+
// perceived "scroll stop" into the next.
|
|
597
|
+
const picked = pickTopmost(observers.pending.values());
|
|
598
|
+
|
|
599
|
+
observers.pending.clear();
|
|
600
|
+
|
|
601
|
+
if (!picked) {
|
|
602
|
+
// No anchor visible / above zone — preserve last hash (RFC §10 #5).
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const newHash = (picked.target as HTMLElement).id;
|
|
607
|
+
|
|
608
|
+
if (!newHash) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const state = router.getState();
|
|
613
|
+
|
|
614
|
+
if (!state) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const currentHash = getUrlContext(state)?.hash ?? "";
|
|
619
|
+
|
|
620
|
+
if (newHash === currentHash) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Emit the same-route same-params hash-only transition. URL plugin
|
|
625
|
+
// writes `state.context.url.hash = newHash` + `hashChanged = true` in
|
|
626
|
+
// its `onTransitionSuccess` claim.
|
|
627
|
+
const opts: HashAwareNavigationOptions = {
|
|
628
|
+
hash: newHash,
|
|
629
|
+
replace: true,
|
|
630
|
+
force: true,
|
|
631
|
+
hashChange: true,
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// Self-emit guard (RFC §5.2): set synchronously around our own
|
|
635
|
+
// `router.navigate()` so the `router.subscribe` callback skips the
|
|
636
|
+
// cooldown setup for spy-emitted transitions — otherwise spy would
|
|
637
|
+
// rate-limit itself to ≤ 2 emits/s, contradicting the ≤ 10/s benchmark
|
|
638
|
+
// target. Test coupling (Q8): preserve exact `.catch(noop).finally(reset)`
|
|
639
|
+
// chain — migrating to `try/finally` over `await router.navigate(...)`
|
|
640
|
+
// changes microtask schedule and breaks "spy continues after rejection".
|
|
641
|
+
selfEmitting = true;
|
|
642
|
+
router
|
|
643
|
+
.navigate(state.name, state.params, opts)
|
|
644
|
+
.catch(() => {
|
|
645
|
+
// Fire-and-forget — suppress expected rejections (concurrent
|
|
646
|
+
// navigate, router stopped, etc.) consistent with `<Link>` adapter
|
|
647
|
+
// patterns.
|
|
648
|
+
})
|
|
649
|
+
.finally(() => {
|
|
650
|
+
selfEmitting = false;
|
|
651
|
+
});
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
// Cooldown setup on user-driven hash transitions. Spy's own emits are
|
|
655
|
+
// distinguished via the synchronous `selfEmitting` flag (see `flush`).
|
|
656
|
+
const unsubscribeRouter = router.subscribe(({ route }) => {
|
|
657
|
+
if (selfEmitting) {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (getUrlContext(route)?.hashChanged) {
|
|
662
|
+
cooldown.start();
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
destroy(): void {
|
|
668
|
+
if (destroyed) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
destroyed = true;
|
|
673
|
+
|
|
674
|
+
// Unsubscribe FIRST to prevent late-arriving router transition
|
|
675
|
+
// callback from calling `cooldown.start()` on a half-destroyed
|
|
676
|
+
// instance. Without this ordering, a transition with `hashChanged:
|
|
677
|
+
// true` firing between subsystem teardown and `unsubscribeRouter()`
|
|
678
|
+
// would re-install a 500ms timer that survives `destroy()`. Verified
|
|
679
|
+
// via Oracle review (Q5/Q7).
|
|
680
|
+
unsubscribeRouter();
|
|
681
|
+
|
|
682
|
+
observers.destroy();
|
|
683
|
+
debouncer.destroy();
|
|
684
|
+
cooldown.destroy();
|
|
685
|
+
detector.destroy();
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
}
|