@real-router/angular 0.4.0 → 0.6.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.
@@ -0,0 +1,70 @@
1
+ import type { Router } from "@real-router/core";
2
+
3
+ export interface DirectionTracker {
4
+ destroy: () => void;
5
+ }
6
+
7
+ const NOOP_INSTANCE: DirectionTracker = Object.freeze({
8
+ destroy: () => {
9
+ /* no-op */
10
+ },
11
+ });
12
+
13
+ /**
14
+ * Track navigation direction (forward / back) and write it to
15
+ * `<html data-nav-direction>` on every leave. CSS / JS readers consume
16
+ * the attribute via `html[data-nav-direction="back"]` selectors or
17
+ * `document.documentElement.dataset.navDirection`.
18
+ *
19
+ * Mechanism-agnostic — works identically whether downstream UI uses CSS
20
+ * `@keyframes`, View Transitions pseudo-elements, or library state
21
+ * (motion's `motion.div initial={{ x: ... }}`).
22
+ *
23
+ * Implementation:
24
+ * - On install, set `data-nav-direction="forward"` baseline.
25
+ * - Attach a `popstate` listener that flips an internal flag to
26
+ * `true`. Browser back/forward navigation triggers popstate; user
27
+ * clicks on `<Link>` / programmatic `router.navigate(...)` do not.
28
+ * - On every `subscribeLeave`, write
29
+ * `popstateFlag ? "back" : "forward"` and reset the flag.
30
+ *
31
+ * Returns `{ destroy }` to clean up the listener and clear the dataset
32
+ * attribute.
33
+ */
34
+ export function createDirectionTracker(router: Router): DirectionTracker {
35
+ if (typeof document === "undefined") {
36
+ return NOOP_INSTANCE;
37
+ }
38
+
39
+ let popstateFlag = false;
40
+
41
+ document.documentElement.dataset.navDirection = "forward";
42
+
43
+ const onPopstate = (): void => {
44
+ popstateFlag = true;
45
+ };
46
+
47
+ // IMPORTANT — listener-ordering: `popstate` fires on `window`, which
48
+ // has no DOM descendants, so capture phase is moot. Listeners are
49
+ // dispatched in registration order. To beat the browser-plugin's own
50
+ // popstate handler, this tracker must be installed **before**
51
+ // `router.usePlugin(browserPluginFactory())` in user code. Otherwise
52
+ // the plugin's handler runs first and synchronously fires
53
+ // `subscribeLeave` while `popstateFlag` is still `false`.
54
+ globalThis.addEventListener("popstate", onPopstate);
55
+
56
+ const offLeave = router.subscribeLeave(() => {
57
+ document.documentElement.dataset.navDirection = popstateFlag
58
+ ? "back"
59
+ : "forward";
60
+ popstateFlag = false;
61
+ });
62
+
63
+ return {
64
+ destroy: () => {
65
+ offLeave();
66
+ globalThis.removeEventListener("popstate", onPopstate);
67
+ delete document.documentElement.dataset.navDirection;
68
+ },
69
+ };
70
+ }
@@ -1,7 +1,11 @@
1
+ export { createDirectionTracker } from "./direction-tracker";
2
+
1
3
  export { createRouteAnnouncer } from "./route-announcer";
2
4
 
3
5
  export { createScrollRestoration } from "./scroll-restore";
4
6
 
7
+ export { createViewTransitions } from "./view-transitions";
8
+
5
9
  export {
6
10
  shouldNavigate,
7
11
  buildHref,
@@ -16,3 +20,7 @@ export type {
16
20
  ScrollRestorationOptions,
17
21
  ScrollRestorationMode,
18
22
  } from "./scroll-restore";
23
+
24
+ export type { DirectionTracker } from "./direction-tracker";
25
+
26
+ export type { ViewTransitions } from "./view-transitions";
@@ -136,7 +136,7 @@ function resolveText(
136
136
  return getCustomText(route);
137
137
  }
138
138
 
139
- const h1Text = h1?.textContent.trim() ?? "";
139
+ const h1Text = (h1?.textContent ?? "").trim();
140
140
  const routeName = route.name.startsWith(INTERNAL_ROUTE_PREFIX)
141
141
  ? ""
142
142
  : route.name;
@@ -0,0 +1,142 @@
1
+ import type { Router } from "@real-router/core";
2
+
3
+ export interface ViewTransitions {
4
+ destroy: () => void;
5
+ }
6
+
7
+ const NOOP_INSTANCE: ViewTransitions = Object.freeze({
8
+ destroy: () => {
9
+ /* no-op */
10
+ },
11
+ });
12
+
13
+ export function createViewTransitions(router: Router): ViewTransitions {
14
+ if (
15
+ typeof document === "undefined" ||
16
+ typeof document.startViewTransition !== "function"
17
+ ) {
18
+ return NOOP_INSTANCE;
19
+ }
20
+
21
+ let closeVT: (() => void) | null = null;
22
+ let currentVT: { skipTransition?: () => void } | null = null;
23
+ // Tracks whether TRANSITION_SUCCESS fired for the current leave. Used to
24
+ // distinguish "benign cleanup abort" (router's async path aborts its own
25
+ // controller in a finally block after successful navigation) from "real
26
+ // cancellation" (concurrent navigate, guard rejection, dispose).
27
+ let successFired = false;
28
+
29
+ const resolveAndClear = (): void => {
30
+ closeVT?.();
31
+ closeVT = null;
32
+ };
33
+
34
+ const offLeave = router.subscribeLeave(({ signal }) => {
35
+ // Reentrant abort: signal already aborted when we're called. Open no VT
36
+ // — router will fall through to TRANSITION_CANCELLED via isCurrentNav()
37
+ // after leave resolves. addEventListener("abort", ...) does not re-fire
38
+ // for past events, so skipping startViewTransition is the safe path.
39
+ if (signal.aborted) {
40
+ return;
41
+ }
42
+
43
+ successFired = false;
44
+ resolveAndClear();
45
+
46
+ // Return a Promise so the router awaits until the browser invokes
47
+ // updateCallback. This ensures old DOM snapshot is captured BEFORE the
48
+ // router commits the new state — giving correct exit→state→entry
49
+ // ordering (vs fire-and-forget, where URL changes before VT captures).
50
+ return new Promise<void>((resolveLeave) => {
51
+ // Capture the resolver synchronously BEFORE startViewTransition() is
52
+ // called. The browser invokes updateCallback in a later task, but
53
+ // router.subscribe (TRANSITION_SUCCESS) can fire before that. If we
54
+ // captured `resolve` inside the callback, subscribe would see closeVT
55
+ // still null and skip resolving — the deferred would hang for 4s
56
+ // until the VT API aborts with TimeoutError.
57
+ const deferred = new Promise<void>((resolve) => {
58
+ closeVT = resolve;
59
+ });
60
+
61
+ signal.addEventListener(
62
+ "abort",
63
+ () => {
64
+ if (successFired) {
65
+ // Router's async path (#finishAsyncNavigation) aborts its own
66
+ // controller in a finally block AFTER completeTransition (and
67
+ // thus AFTER subscribe fired). This is cleanup, not
68
+ // cancellation — VT is progressing normally, do nothing.
69
+ return;
70
+ }
71
+
72
+ // Real cancellation (concurrent navigate, dispose). Resolve the
73
+ // deferred so updateCallback can complete, skip the VT so no
74
+ // stale animation leaks, and unblock the router if the abort
75
+ // fires before updateCallback was invoked.
76
+ resolveAndClear();
77
+ currentVT?.skipTransition?.();
78
+ resolveLeave();
79
+ },
80
+ { once: true },
81
+ );
82
+
83
+ try {
84
+ currentVT = document.startViewTransition(() => {
85
+ // Resolving here unblocks the router at the moment the browser
86
+ // enters updateCallback — by spec, old DOM snapshot is captured
87
+ // before this callback runs. Router now proceeds through
88
+ // activation guards and setState; the VT animation waits on
89
+ // `deferred`, which is resolved from router.subscribe after a
90
+ // task-queue tick (see NOTE on setTimeout below).
91
+ resolveLeave();
92
+
93
+ return deferred;
94
+ });
95
+ } catch {
96
+ // Defensive: spec says startViewTransition doesn't throw under
97
+ // normal conditions, but Chromium has had edge cases (detached
98
+ // document, extension interference). Clean up and unblock router.
99
+ resolveAndClear();
100
+ resolveLeave();
101
+ }
102
+ });
103
+ });
104
+
105
+ const offSuccess = router.subscribe(() => {
106
+ const resolver = closeVT;
107
+
108
+ successFired = true;
109
+ closeVT = null;
110
+
111
+ if (resolver === null) {
112
+ currentVT = null;
113
+ } else {
114
+ // CRITICAL: CANNOT use requestAnimationFrame here. When the router
115
+ // takes the async path (leave returned a Promise), subscribe fires
116
+ // AFTER the browser has already transitioned VT into the
117
+ // "update-callback-called" phase. In that phase Chromium sets
118
+ // rendering suppression to true, which ALSO blocks rAF callbacks.
119
+ // rAF would never fire → deferred never resolves → browser aborts
120
+ // vt.ready with TimeoutError after 4s (observed in Chromium).
121
+ //
122
+ // setTimeout runs on the task queue independent of the rendering
123
+ // pipeline, so it fires regardless of suppression. React's scheduler
124
+ // uses MessageChannel tasks, which are queued before our setTimeout,
125
+ // so the new DOM is committed by the time our callback runs.
126
+ setTimeout(() => {
127
+ resolver();
128
+ currentVT = null;
129
+ }, 0);
130
+ }
131
+ });
132
+
133
+ return {
134
+ destroy: () => {
135
+ offLeave();
136
+ offSuccess();
137
+ currentVT?.skipTransition?.();
138
+ currentVT = null;
139
+ resolveAndClear();
140
+ },
141
+ };
142
+ }
@@ -11,3 +11,19 @@ export { injectRouteUtils } from "./injectRouteUtils";
11
11
  export { injectRouterTransition } from "./injectRouterTransition";
12
12
 
13
13
  export { injectIsActiveRoute } from "./injectIsActiveRoute";
14
+
15
+ export { injectRouteExit } from "./injectRouteExit";
16
+
17
+ export { injectRouteEnter } from "./injectRouteEnter";
18
+
19
+ export type {
20
+ RouteExitContext,
21
+ RouteExitHandler,
22
+ UseRouteExitOptions,
23
+ } from "./injectRouteExit";
24
+
25
+ export type {
26
+ RouteEnterContext,
27
+ RouteEnterHandler,
28
+ UseRouteEnterOptions,
29
+ } from "./injectRouteEnter";
@@ -2,8 +2,29 @@ import { injectOrThrow } from "./injectOrThrow";
2
2
  import { ROUTE } from "../providers";
3
3
 
4
4
  import type { RouteSignals } from "../types";
5
- import type { Params } from "@real-router/core";
5
+ import type { Signal } from "@angular/core";
6
+ import type { Params, State } from "@real-router/core";
7
+ import type { RouteSnapshot } from "@real-router/sources";
6
8
 
7
- export function injectRoute<P extends Params = Params>(): RouteSignals<P> {
8
- return injectOrThrow(ROUTE, "injectRoute") as RouteSignals<P>;
9
+ export function injectRoute<P extends Params = Params>(): Omit<
10
+ RouteSignals<P>,
11
+ "routeState"
12
+ > & {
13
+ readonly routeState: Signal<
14
+ Omit<RouteSnapshot<P>, "route"> & { route: State<P> }
15
+ >;
16
+ } {
17
+ const signals = injectOrThrow(ROUTE, "injectRoute") as RouteSignals<P>;
18
+
19
+ if (!signals.routeState().route) {
20
+ throw new Error(
21
+ "injectRoute called with no active route. Did you forget to await router.start() before rendering, or is the router stopped/disposed?",
22
+ );
23
+ }
24
+
25
+ return signals as Omit<RouteSignals<P>, "routeState"> & {
26
+ readonly routeState: Signal<
27
+ Omit<RouteSnapshot<P>, "route"> & { route: State<P> }
28
+ >;
29
+ };
9
30
  }
@@ -0,0 +1,122 @@
1
+ import { assertInInjectionContext, effect } from "@angular/core";
2
+
3
+ import { injectRoute } from "./injectRoute";
4
+
5
+ import type { State } from "@real-router/core";
6
+
7
+ export interface RouteEnterContext {
8
+ /** The route that was just activated. */
9
+ route: State;
10
+ /** The route that was active immediately before this navigation. */
11
+ previousRoute: State;
12
+ }
13
+
14
+ export type RouteEnterHandler = (context: RouteEnterContext) => void;
15
+
16
+ export interface UseRouteEnterOptions {
17
+ /**
18
+ * Skip the handler when `route.name === previousRoute.name`
19
+ * (sort/filter/query-only navigations on the same route). Default:
20
+ * `true`. Symmetric with `injectRouteExit`'s same-name option.
21
+ */
22
+ skipSameRoute?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Fire `handler` once when the component is created as a result of a
27
+ * navigation. Mirror of `injectRouteExit` for the entry side.
28
+ *
29
+ * What this function covers that an ad-hoc `effect()` + `injectRoute()`
30
+ * doesn't:
31
+ *
32
+ * - **Skip-initial**: handler is skipped when there is no
33
+ * `route.transition.from` (i.e. first-load mount). Most consumers
34
+ * want to fire side effects only on real navigations, not on
35
+ * hydration.
36
+ * - **Same-route skip** (default): handler is skipped when
37
+ * `route.transition.from === route.name`. Sort/filter/query-only
38
+ * navigations re-run the effect (because the `route` reference
39
+ * changes), but they are not "entries" in the animation / analytics
40
+ * sense. Opt out with `skipSameRoute: false`.
41
+ * - **Mount-time `route` / `previousRoute` snapshot**: handler receives
42
+ * the values that were live at the moment of effect activation.
43
+ *
44
+ * Effect cleanup is wired through the injection context's `DestroyRef`
45
+ * (Angular's `effect()` ties into the active context automatically).
46
+ * Must be called within an injection context (constructor, field
47
+ * initializer, or `runInInjectionContext`).
48
+ *
49
+ * **Handler reactivity (Angular):** `inject*` functions run **once**
50
+ * during component construction; `handler` is captured in closure at the
51
+ * call site. The common Angular pattern is to pass a class method —
52
+ * its identity is stable across change detection. To vary behavior
53
+ * over time, read signals **inside** the handler body.
54
+ *
55
+ * @example Direction-aware entry animation
56
+ * ```ts
57
+ * \@Component({ ... })
58
+ * class PageComponent {
59
+ * private el = inject(ElementRef<HTMLElement>);
60
+ *
61
+ * constructor() {
62
+ * injectRouteEnter(({ route }) => {
63
+ * const direction = route.context.browser?.direction;
64
+ * this.el.nativeElement.classList.add(
65
+ * direction === "back" ? "slide-from-left" : "slide-from-right",
66
+ * );
67
+ * });
68
+ * }
69
+ * }
70
+ * ```
71
+ *
72
+ * @example Analytics page-enter event (skip-initial built-in)
73
+ * ```ts
74
+ * injectRouteEnter(({ route, previousRoute }) => {
75
+ * analytics.track("page_enter", {
76
+ * route: route.name,
77
+ * from: previousRoute.name,
78
+ * });
79
+ * });
80
+ * ```
81
+ */
82
+ export function injectRouteEnter(
83
+ handler: RouteEnterHandler,
84
+ options?: UseRouteEnterOptions,
85
+ ): void {
86
+ assertInInjectionContext(injectRouteEnter);
87
+
88
+ const { routeState } = injectRoute();
89
+ const skipSameRoute = options?.skipSameRoute ?? true;
90
+ let lastHandledRoute: State | null = null;
91
+
92
+ effect(() => {
93
+ const { route, previousRoute } = routeState();
94
+
95
+ // Early-exit guards, top-down:
96
+ //
97
+ // - **Skip-initial**: `state.transition.from` is undefined only
98
+ // for the very first state committed by `router.start()`.
99
+ // - **Skip-same-route**: query-only navigations have
100
+ // `transition.from === route.name`. Opt-out via
101
+ // `skipSameRoute: false`.
102
+ // - **Defensive dedupe + missing `previousRoute`**: same `route`
103
+ // ref between effect re-runs is unexpected on Angular (the
104
+ // signal only fires on real reference changes); `!previousRoute`
105
+ // is unreachable once `transition.from` is set (core populates
106
+ // them together). Both kept for parity with React; v8-ignored.
107
+ if (!route.transition.from) {
108
+ return;
109
+ }
110
+ if (skipSameRoute && route.transition.from === route.name) {
111
+ return;
112
+ }
113
+ /* v8 ignore start */
114
+ if (lastHandledRoute === route || !previousRoute) {
115
+ return;
116
+ }
117
+ /* v8 ignore stop */
118
+
119
+ lastHandledRoute = route;
120
+ handler({ route, previousRoute });
121
+ });
122
+ }
@@ -0,0 +1,118 @@
1
+ import { DestroyRef, assertInInjectionContext, inject } from "@angular/core";
2
+
3
+ import { injectRouter } from "./injectRouter";
4
+
5
+ import type { State } from "@real-router/core";
6
+
7
+ export interface RouteExitContext {
8
+ /** The route being left. */
9
+ route: State;
10
+ /** The route being navigated to. */
11
+ nextRoute: State;
12
+ /**
13
+ * AbortSignal that fires when this navigation is superseded by a later
14
+ * one (rapid clicks). Already filtered: when the handler runs,
15
+ * `signal.aborted` is guaranteed to be `false`. Use
16
+ * `signal.addEventListener("abort", cleanup, { once: true })` for
17
+ * cleanup that must run on cancellation.
18
+ */
19
+ signal: AbortSignal;
20
+ }
21
+
22
+ export interface UseRouteExitOptions {
23
+ /**
24
+ * Skip the handler when `route.name === nextRoute.name`
25
+ * (sort/filter/query-only navigations on the same route). Default:
26
+ * `true`.
27
+ */
28
+ skipSameRoute?: boolean;
29
+ }
30
+
31
+ export type RouteExitHandler = (
32
+ context: RouteExitContext,
33
+ ) => void | Promise<void>;
34
+
35
+ /**
36
+ * Subscribe to the router's leave-window with the universal guards baked
37
+ * in. Wraps `router.subscribeLeave` so consumers don't repeat the same
38
+ * boilerplate every time:
39
+ *
40
+ * - **Reentrant abort pre-check**: if `signal.aborted` is already `true`
41
+ * when the handler would run (rapid navigation superseded a slower
42
+ * one), the handler is skipped entirely.
43
+ * - **Same-route skip**: by default, `route.name === nextRoute.name`
44
+ * short-circuits the handler — query-only navigations skip the work.
45
+ * Opt out with `skipSameRoute: false`.
46
+ *
47
+ * Cleanup is bound to the injection context's `DestroyRef`. Must be
48
+ * called within an injection context (constructor, field initializer,
49
+ * or `runInInjectionContext`).
50
+ *
51
+ * If the handler returns a Promise, the router blocks on it. If the
52
+ * Promise resolves, navigation proceeds. If it rejects, the router emits
53
+ * `TRANSITION_CANCELLED`.
54
+ *
55
+ * **Handler reactivity (Angular):** `inject*` functions run **once**
56
+ * during component construction; `handler` is captured in closure at the
57
+ * call site. The common Angular pattern is to pass a class method
58
+ * (`this.onExit.bind(this)` or an arrow-property) — its identity is
59
+ * stable across change detection. To vary behavior over time, read
60
+ * signals **inside** the handler body — do not rely on swapping the
61
+ * handler reference.
62
+ *
63
+ * @example Animation
64
+ * ```ts
65
+ * \@Component({ ... })
66
+ * class FadeOutComponent {
67
+ * private el = inject(ElementRef<HTMLElement>);
68
+ *
69
+ * constructor() {
70
+ * injectRouteExit(async ({ signal }) => {
71
+ * const el = this.el.nativeElement;
72
+ * el.classList.add("fade-out");
73
+ * const cleanup = () => el.classList.remove("fade-out");
74
+ * signal.addEventListener("abort", cleanup, { once: true });
75
+ * try {
76
+ * el.getBoundingClientRect();
77
+ * await Promise.allSettled(el.getAnimations().map((a) => a.finished));
78
+ * } finally {
79
+ * cleanup();
80
+ * }
81
+ * });
82
+ * }
83
+ * }
84
+ * ```
85
+ *
86
+ * @example Auto-save form draft
87
+ * ```ts
88
+ * injectRouteExit(async ({ signal }) => {
89
+ * if (this.formState.dirty) {
90
+ * await this.api.saveDraft(this.formState, { signal });
91
+ * }
92
+ * });
93
+ * ```
94
+ */
95
+ export function injectRouteExit(
96
+ handler: RouteExitHandler,
97
+ options?: UseRouteExitOptions,
98
+ ): void {
99
+ assertInInjectionContext(injectRouteExit);
100
+
101
+ const router = injectRouter();
102
+ const destroyRef = inject(DestroyRef);
103
+ const skipSameRoute = options?.skipSameRoute ?? true;
104
+
105
+ const off = router.subscribeLeave(({ route, nextRoute, signal }) => {
106
+ if (skipSameRoute && route.name === nextRoute.name) {
107
+ return;
108
+ }
109
+
110
+ if (signal.aborted) {
111
+ return;
112
+ }
113
+
114
+ return handler({ route, nextRoute, signal });
115
+ });
116
+
117
+ destroyRef.onDestroy(off);
118
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,17 @@ export {
10
10
  injectRouteUtils,
11
11
  injectRouterTransition,
12
12
  injectIsActiveRoute,
13
+ injectRouteExit,
14
+ injectRouteEnter,
15
+ } from "./functions";
16
+
17
+ export type {
18
+ RouteExitContext,
19
+ RouteExitHandler,
20
+ UseRouteExitOptions,
21
+ RouteEnterContext,
22
+ RouteEnterHandler,
23
+ UseRouteEnterOptions,
13
24
  } from "./functions";
14
25
 
15
26
  export { RouteView } from "./components/RouteView";
package/src/providers.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ ApplicationRef,
2
3
  DestroyRef,
3
4
  InjectionToken,
4
5
  inject,
@@ -9,7 +10,7 @@ import {
9
10
  import { getNavigator, type Router, type Navigator } from "@real-router/core";
10
11
  import { createRouteSource } from "@real-router/sources";
11
12
 
12
- import { createScrollRestoration } from "./dom-utils";
13
+ import { createScrollRestoration, createViewTransitions } from "./dom-utils";
13
14
  import { sourceToSignal } from "./sourceToSignal";
14
15
 
15
16
  import type { ScrollRestorationOptions } from "./dom-utils";
@@ -23,6 +24,7 @@ export const ROUTE = new InjectionToken<RouteSignals>("ROUTE");
23
24
 
24
25
  export interface RealRouterOptions {
25
26
  scrollRestoration?: ScrollRestorationOptions;
27
+ viewTransitions?: boolean;
26
28
  }
27
29
 
28
30
  export function provideRealRouter(
@@ -57,5 +59,37 @@ export function provideRealRouter(
57
59
  );
58
60
  }
59
61
 
62
+ if (options?.viewTransitions === true) {
63
+ providers.push(
64
+ provideEnvironmentInitializer(() => {
65
+ const appRef = inject(ApplicationRef);
66
+
67
+ // Force synchronous change detection on every transition success
68
+ // BEFORE the VT utility resolves its deferred. The utility uses
69
+ // `setTimeout(0)` to release the new-snapshot capture, which is
70
+ // load-bearing because Chromium blocks rAF callbacks while VT sits
71
+ // in the `update-callback-called` phase. Angular's zoneless CD is
72
+ // rAF-driven by default — without this synchronous tick the new
73
+ // DOM is not committed when the browser captures the new snapshot,
74
+ // so old and new snapshots end up identical and animations finish
75
+ // in ~0 ms with no visible work (the inner-route `products.list ↔
76
+ // products.detail` morph in the example example was the canary).
77
+ // Subscribers fire in registration order; this one runs BEFORE
78
+ // `createViewTransitions` registers its own subscriber,
79
+ // guaranteeing CD completes first.
80
+ const offTick = router.subscribe(() => {
81
+ appRef.tick();
82
+ });
83
+
84
+ const vt = createViewTransitions(router);
85
+
86
+ inject(DestroyRef).onDestroy(() => {
87
+ offTick();
88
+ vt.destroy();
89
+ });
90
+ }),
91
+ );
92
+ }
93
+
60
94
  return makeEnvironmentProviders(providers);
61
95
  }