@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.
@@ -35,6 +35,65 @@ interface ScrollRestorationOptions {
35
35
  storageKey?: string | undefined;
36
36
  }
37
37
 
38
+ /**
39
+ * Router-coordinated scroll spy (#575).
40
+ *
41
+ * On `IntersectionObserver` notifications the utility picks the topmost
42
+ * visible anchor inside the configured scroll container and emits a forced
43
+ * same-route transition with `{ hash, replace: true, force: true, hashChange:
44
+ * true }` through `router.navigate(...)`. The URL plugin
45
+ * (`@real-router/browser-plugin` or `@real-router/navigation-plugin`) updates
46
+ * `state.context.url.hash` so sibling hash-aware `<Link hash>` re-highlights
47
+ * via the standard `createActiveRouteSource` pipeline.
48
+ *
49
+ * **Anti-flicker gates** (RFC §5.2):
50
+ * 1. `getTransitionSource(router).getSnapshot().isTransitioning` — skip emits
51
+ * while a transition is in-flight (re-entrant lock).
52
+ * 2. `coolingDown` — set on a user-driven hash transition (e.g. `<Link hash>`
53
+ * click + smooth `scrollIntoView`). Cleared on `scrollend` or after a
54
+ * 500ms safety timeout. Spy's own emits are excluded via the synchronous
55
+ * `selfEmitting` flag — required so the spy doesn't rate-limit itself.
56
+ *
57
+ * **Self-healing** (RFC §7.3): if the initial URL contains a hash without a
58
+ * matching `id` (e.g. `/page#nonexistent`), the first IO event emitted right
59
+ * after observe()-ing picks the topmost real anchor and corrects the URL.
60
+ *
61
+ * **Hash-only transition pipeline cost** (RFC §5.3): for same-route same-
62
+ * params hash-only navigations, `getTransitionPath` returns empty
63
+ * `toDeactivate` / `toActivate` arrays, so `runGuards` is a no-op. The only
64
+ * work is the URL plugin's `onTransitionSuccess` write and the
65
+ * `getTransitionSource` flip — cheap.
66
+ *
67
+ * **Architecture**: decomposed into 4 private subsystem closure factories
68
+ * (`createUrlPluginDetector`, `createCooldown`, `createDebouncer`,
69
+ * `createObserverPair`). The main `createScrollSpy` wires them together
70
+ * around the shared `silenced` / `destroyed` / `selfEmitting` flags and the
71
+ * `flush()` emit logic. Each subsystem owns its state + cleanup; `destroy()`
72
+ * delegates to each. See section banners below.
73
+ *
74
+ * @returns A `ScrollSpy` handle whose `destroy()` is idempotent.
75
+ */
76
+ interface ScrollSpyOptions {
77
+ /**
78
+ * CSS selector for anchor candidates. Empty string `""` or `undefined`
79
+ * disables the spy (returns a NOOP handle). Common values:
80
+ * `"[id]"`, `"[id]:is(h1,h2,h3)"`, `"section[id]"`.
81
+ */
82
+ selector: string;
83
+ /**
84
+ * `IntersectionObserver` `rootMargin`. Default
85
+ * `"-20% 0px -60% 0px"` — an anchor is considered "active" once it crosses
86
+ * into the top 20 % of the viewport (or scroll container).
87
+ */
88
+ rootMargin?: string | undefined;
89
+ /**
90
+ * Lazy getter for the scrollable container. Resolved on every event.
91
+ * `null` (or missing getter) falls back to the window viewport
92
+ * (`root: null` on the `IntersectionObserver`).
93
+ */
94
+ scrollContainer?: (() => HTMLElement | null) | undefined;
95
+ }
96
+
38
97
  interface RouteSignals<P extends Params = Params> {
39
98
  readonly routeState: Signal<RouteSnapshot<P>>;
40
99
  readonly navigator: Navigator;
@@ -49,6 +108,7 @@ declare const NAVIGATOR: InjectionToken<Navigator>;
49
108
  declare const ROUTE: InjectionToken<RouteSignals<_real_router_types.Params>>;
50
109
  interface RealRouterOptions {
51
110
  scrollRestoration?: ScrollRestorationOptions;
111
+ scrollSpy?: ScrollSpyOptions;
52
112
  viewTransitions?: boolean;
53
113
  }
54
114
  declare function provideRealRouter(router: Router, options?: RealRouterOptions): EnvironmentProviders;
@@ -119,6 +179,8 @@ interface RealRouterFactoryOptions<TDeps extends DefaultDependencies = DefaultDe
119
179
  deps?: RequestDepsFactory<TDeps>;
120
180
  /** Optional scroll restoration — same semantics as `provideRealRouter`. */
121
181
  scrollRestoration?: ScrollRestorationOptions;
182
+ /** Optional scroll spy — same semantics as `provideRealRouter`. */
183
+ scrollSpy?: ScrollSpyOptions;
122
184
  /** Optional view transitions — same semantics as `provideRealRouter`. */
123
185
  viewTransitions?: boolean;
124
186
  }
@@ -1 +1 @@
1
- {"version":3,"file":"real-router-angular.d.ts","sources":["../../src/dom-utils/scroll-restore.ts","../../src/types.ts","../../src/providers.ts","../../src/providersFactory.ts","../../src/sourceToSignal.ts","../../src/functions/injectRouter.ts","../../src/functions/injectNavigator.ts","../../src/functions/injectRoute.ts","../../src/functions/injectRouteNode.ts","../../src/functions/injectRouteUtils.ts","../../src/functions/injectRouterTransition.ts","../../src/functions/injectIsActiveRoute.ts","../../src/functions/injectRouteExit.ts","../../src/functions/injectRouteEnter.ts","../../src/directives/RouteMatch.ts","../../src/directives/RouteNotFound.ts","../../src/directives/RouteSelf.ts","../../src/components/RouteView.ts","../../src/components/RouterErrorBoundary.ts","../../src/components/NavigationAnnouncer.ts","../../src/directives/RealLink.ts","../../src/directives/RealLinkActive.ts"],"mappings":"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","names":[]}
1
+ {"version":3,"file":"real-router-angular.d.ts","sources":["../../src/dom-utils/scroll-restore.ts","../../src/dom-utils/scroll-spy.ts","../../src/types.ts","../../src/providers.ts","../../src/providersFactory.ts","../../src/sourceToSignal.ts","../../src/functions/injectRouter.ts","../../src/functions/injectNavigator.ts","../../src/functions/injectRoute.ts","../../src/functions/injectRouteNode.ts","../../src/functions/injectRouteUtils.ts","../../src/functions/injectRouterTransition.ts","../../src/functions/injectIsActiveRoute.ts","../../src/functions/injectRouteExit.ts","../../src/functions/injectRouteEnter.ts","../../src/directives/RouteMatch.ts","../../src/directives/RouteNotFound.ts","../../src/directives/RouteSelf.ts","../../src/components/RouteView.ts","../../src/components/RouterErrorBoundary.ts","../../src/components/NavigationAnnouncer.ts","../../src/directives/RealLink.ts","../../src/directives/RealLinkActive.ts"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-router/angular",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "commonjs",
5
5
  "description": "Angular 21 integration for Real-Router",
6
6
  "exports": {
@@ -41,7 +41,7 @@
41
41
  "license": "MIT",
42
42
  "sideEffects": false,
43
43
  "dependencies": {
44
- "@real-router/core": "^0.54.1",
44
+ "@real-router/core": "^0.54.6",
45
45
  "@real-router/route-utils": "^0.2.2",
46
46
  "@real-router/sources": "^0.8.3"
47
47
  },
@@ -4,6 +4,8 @@ export { createRouteAnnouncer } from "./route-announcer";
4
4
 
5
5
  export { createScrollRestoration } from "./scroll-restore";
6
6
 
7
+ export { createScrollSpy } from "./scroll-spy";
8
+
7
9
  export { createViewTransitions } from "./view-transitions";
8
10
 
9
11
  export {
@@ -22,6 +24,8 @@ export type {
22
24
  ScrollRestorationMode,
23
25
  } from "./scroll-restore";
24
26
 
27
+ export type { ScrollSpy, ScrollSpyOptions } from "./scroll-spy";
28
+
25
29
  export type { DirectionTracker } from "./direction-tracker";
26
30
 
27
31
  export type { ViewTransitions } from "./view-transitions";
@@ -2,6 +2,14 @@ import type { Router, State } from "@real-router/core";
2
2
 
3
3
  const DEFAULT_STORAGE_KEY = "real-router:scroll";
4
4
 
5
+ // Bounded retry budget for resolving a late-mounting scroll container on the
6
+ // restore path. A per-route container (e.g. an `overflow:auto` div rendered
7
+ // only on one route) can be committed to the DOM a few frames after the
8
+ // navigation settles — heavier routes paint later than the subscribe's rAF.
9
+ // ~10 frames (≈160ms at 60fps) comfortably covers a React commit of a large
10
+ // route without being perceptible. See the doc-block on `restorePos`.
11
+ const RESTORE_RETRY_FRAMES = 10;
12
+
5
13
  const NOOP_INSTANCE: { destroy: () => void } = Object.freeze({
6
14
  destroy: () => {
7
15
  /* no-op */
@@ -135,6 +143,74 @@ export function createScrollRestoration(
135
143
  }
136
144
  };
137
145
 
146
+ // Restore path (back / traverse / reload). Unlike `writePos`, this tolerates a
147
+ // scroll container that both MOUNTS and LAYS OUT a few frames AFTER the
148
+ // navigation settles.
149
+ //
150
+ // The capture-side `readPos` always runs against an already-mounted DOM (the
151
+ // route being left). On restore the target route — and its container — is
152
+ // still being committed by the view layer. The subscribe callback schedules a
153
+ // single rAF; for a heavy route (e.g. a long virtual list) the framework's
154
+ // commit can land AFTER that frame. Two distinct failures follow, each losing
155
+ // the saved position (Scenario 6 e2e, reproduced under CI's slower runner):
156
+ //
157
+ // 1. Container not mounted yet → `getContainer()` is `null`, the scroll
158
+ // silently falls back to `window`, which on a container-only route has
159
+ // nothing to scroll.
160
+ // 2. Container mounted but its content not laid out yet → `scrollHeight`
161
+ // is still small, so a single `scrollTo({ top })` clamps short of the
162
+ // saved position and never re-applies once layout grows.
163
+ //
164
+ // With no `scrollContainer` getter the target is always `window`, present
165
+ // from the first frame — restore in a single shot (unchanged behaviour). When
166
+ // a getter is configured we cannot tell "this route legitimately uses window"
167
+ // from "the container is still mounting", so re-apply the scroll on every
168
+ // frame for a bounded budget: window as a fallback while the container is
169
+ // absent (harmless clamp on container routes), the container itself once it
170
+ // appears. For instant restores we stop early the moment the position sticks;
171
+ // smooth restores animate asynchronously, so they run the full budget. The
172
+ // frame budget is the hard backstop against an unreachable target (saved
173
+ // position taller than the restored content).
174
+ const restorePos = (top: number): void => {
175
+ if (!getContainer) {
176
+ globalThis.scrollTo({ top, left: 0, behavior });
177
+
178
+ return;
179
+ }
180
+
181
+ let frames = 0;
182
+
183
+ const attempt = (): void => {
184
+ if (destroyed) {
185
+ return;
186
+ }
187
+
188
+ const element = getContainer();
189
+
190
+ if (element) {
191
+ element.scrollTo({ top, left: 0, behavior });
192
+
193
+ // Instant restore landed within rounding tolerance → done; no point
194
+ // re-applying. Smooth restore never matches synchronously, so let it
195
+ // ride the budget.
196
+ if (behavior !== "smooth" && Math.abs(element.scrollTop - top) <= 1) {
197
+ return;
198
+ }
199
+ } else {
200
+ globalThis.scrollTo({ top, left: 0, behavior });
201
+ }
202
+
203
+ if (frames >= RESTORE_RETRY_FRAMES) {
204
+ return;
205
+ }
206
+
207
+ frames += 1;
208
+ requestAnimationFrame(attempt);
209
+ };
210
+
211
+ attempt();
212
+ };
213
+
138
214
  const scrollToHashOrTop = (route: State): void => {
139
215
  // URL plugin path (#532): `state.context.url.hash` is the source of truth
140
216
  // when one of the URL plugins (browser-plugin / navigation-plugin) is
@@ -240,20 +316,26 @@ export function createScrollRestoration(
240
316
  return;
241
317
  }
242
318
 
243
- if (route.transition.replace || nav?.navigationType === "replace") {
244
- return;
245
- }
246
-
247
- // Both arms are required: `transition.reload` only fires for programmatic
248
- // `router.navigate({reload:true})`. F5 under navigation-plugin primes
249
- // `nav.navigationType === "reload"` via #531 getActivationType but leaves
250
- // opts.reload undefined, so dropping the plugin arm would regress F5
251
- // scroll-restore. Same belt-and-suspenders pattern is used for replace
252
- // above. Browser-plugin's F5 is not covered (no priming, out of scope).
319
+ // Restore branches (reload, back/traverse) MUST be evaluated before the
320
+ // replace-skip below. Since #657 lifted `replace` into TransitionMeta, a
321
+ // history TRAVERSAL (back/forward) under navigation-plugin carries
322
+ // `transition.replace === true` — a traversal reuses an existing history
323
+ // entry, which is replace-shaped at the history level. If the replace-skip
324
+ // ran first it would swallow every back/forward navigation and restore
325
+ // would never fire (the Scenario 6 e2e regression). Genuine in-place
326
+ // replaces (`router.navigate({ replace: true })`, navigateToNotFound) are
327
+ // not traversals and fall through to the skip below.
328
+ //
329
+ // Both arms of each check are required: `transition.reload` only fires for
330
+ // programmatic `router.navigate({reload:true})`. F5 under navigation-plugin
331
+ // primes `nav.navigationType === "reload"` via #531 getActivationType but
332
+ // leaves opts.reload undefined, so dropping the plugin arm would regress F5
333
+ // scroll-restore. Browser-plugin's F5 is not covered (no priming, out of
334
+ // scope).
253
335
  if (route.transition.reload || nav?.navigationType === "reload") {
254
336
  const key = safeKeyOf(route);
255
337
 
256
- writePos(key === null ? 0 : (loadStore()[key] ?? 0));
338
+ restorePos(key === null ? 0 : (loadStore()[key] ?? 0));
257
339
 
258
340
  return;
259
341
  }
@@ -261,11 +343,16 @@ export function createScrollRestoration(
261
343
  if (nav?.direction === "back" || nav?.navigationType === "traverse") {
262
344
  const key = safeKeyOf(route);
263
345
 
264
- writePos(key === null ? 0 : (loadStore()[key] ?? 0));
346
+ restorePos(key === null ? 0 : (loadStore()[key] ?? 0));
265
347
 
266
348
  return;
267
349
  }
268
350
 
351
+ // Genuine in-place replace (not a traversal) — leave scroll untouched.
352
+ if (route.transition.replace || nav?.navigationType === "replace") {
353
+ return;
354
+ }
355
+
269
356
  scrollToHashOrTop(route);
270
357
  });
271
358
  });