@real-router/svelte 0.6.0 → 0.7.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 +56 -2
- package/dist/RouterProvider.svelte +9 -0
- package/dist/RouterProvider.svelte.d.ts +1 -0
- package/dist/composables/useRouteEnter.svelte.d.ts +68 -0
- package/dist/composables/useRouteEnter.svelte.js +93 -0
- package/dist/composables/useRouteExit.svelte.d.ts +83 -0
- package/dist/composables/useRouteExit.svelte.js +74 -0
- package/dist/dom-utils/direction-tracker.d.ts +26 -0
- package/dist/dom-utils/direction-tracker.js +57 -0
- package/dist/dom-utils/index.d.ts +4 -0
- package/dist/dom-utils/index.js +2 -0
- package/dist/dom-utils/route-announcer.js +1 -1
- package/dist/dom-utils/view-transitions.d.ts +5 -0
- package/dist/dom-utils/view-transitions.js +118 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/package.json +4 -4
- package/src/RouterProvider.svelte +9 -0
- package/src/composables/useRouteEnter.svelte.ts +120 -0
- package/src/composables/useRouteExit.svelte.ts +113 -0
- package/src/index.ts +16 -0
package/README.md
CHANGED
|
@@ -69,6 +69,8 @@ All composables must be called during component initialization (not inside `$eff
|
|
|
69
69
|
| `useRouteNode(name)` | `{ navigator, route: { current }, previousRoute: { current } }` | `.current` when node activates/deactivates |
|
|
70
70
|
| `useRouteUtils()` | `RouteUtils` | Never |
|
|
71
71
|
| `useRouterTransition()` | `{ current: RouterTransitionSnapshot }` | `.current` on transition start/end |
|
|
72
|
+
| `useRouteExit(handler, options?)` | `void` — wraps `subscribeLeave` with abort + same-route guards | Never (handler captured at init) |
|
|
73
|
+
| `useRouteEnter(handler, options?)` | `void` — fires once on nav-driven mount via `$effect` + `transition.from` | Never (handler captured at init) |
|
|
72
74
|
|
|
73
75
|
```svelte
|
|
74
76
|
<!-- useRouteNode — updates only when "users.*" changes -->
|
|
@@ -111,6 +113,46 @@ All composables must be called during component initialization (not inside `$eff
|
|
|
111
113
|
{/if}
|
|
112
114
|
```
|
|
113
115
|
|
|
116
|
+
```svelte
|
|
117
|
+
<!-- useRouteExit — exit animations, draft autosave, AbortSignal-aware cleanup -->
|
|
118
|
+
<script lang="ts">
|
|
119
|
+
import { useRouteExit } from "@real-router/svelte";
|
|
120
|
+
|
|
121
|
+
let el: HTMLDivElement;
|
|
122
|
+
|
|
123
|
+
useRouteExit(async ({ signal }) => {
|
|
124
|
+
if (!el) return;
|
|
125
|
+
el.classList.add("fade-out");
|
|
126
|
+
const cleanup = () => el.classList.remove("fade-out");
|
|
127
|
+
signal.addEventListener("abort", cleanup, { once: true });
|
|
128
|
+
el.getBoundingClientRect(); // style flush
|
|
129
|
+
await Promise.allSettled(el.getAnimations().map((a) => a.finished));
|
|
130
|
+
cleanup();
|
|
131
|
+
});
|
|
132
|
+
</script>
|
|
133
|
+
|
|
134
|
+
<div bind:this={el}>...</div>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```svelte
|
|
138
|
+
<!-- useRouteEnter — page-enter analytics, focus management, entry animations -->
|
|
139
|
+
<script lang="ts">
|
|
140
|
+
import { useRouteEnter } from "@real-router/svelte";
|
|
141
|
+
|
|
142
|
+
useRouteEnter(({ route, previousRoute }) => {
|
|
143
|
+
analytics.track("page_enter", {
|
|
144
|
+
route: route.name,
|
|
145
|
+
from: previousRoute.name,
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
</script>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
> **Svelte handler-reactivity:** composables run once at init, so `handler` is
|
|
152
|
+
> captured at hook-call time. To vary behavior over time, read
|
|
153
|
+
> `$state` / `$derived` **inside** the handler body. See [CLAUDE.md](./CLAUDE.md)
|
|
154
|
+
> → "useRouteExit / useRouteEnter Handler Is Captured At Init".
|
|
155
|
+
|
|
114
156
|
## Components
|
|
115
157
|
|
|
116
158
|
### `<Link>`
|
|
@@ -375,12 +417,24 @@ Opt-in preservation of scroll position across navigations:
|
|
|
375
417
|
|
|
376
418
|
Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"manual"`. 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.
|
|
377
419
|
|
|
420
|
+
## View Transitions
|
|
421
|
+
|
|
422
|
+
Opt-in animated route transitions via the browser's [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API):
|
|
423
|
+
|
|
424
|
+
```svelte
|
|
425
|
+
<RouterProvider {router} viewTransitions>
|
|
426
|
+
<!-- Your app -->
|
|
427
|
+
</RouterProvider>
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Reactive via `$effect` — toggling the prop creates/destroys the utility. No-op on unsupported browsers (Firefox as of 2026-04, SSR). Customization is pure CSS via `::view-transition-*` pseudo-elements and `view-transition-name` for hero morphs. See [View Transitions guide](https://github.com/greydragon888/real-router/wiki/View-Transitions) for patterns.
|
|
431
|
+
|
|
378
432
|
## Documentation
|
|
379
433
|
|
|
380
434
|
Full documentation: [Wiki](https://github.com/greydragon888/real-router/wiki)
|
|
381
435
|
|
|
382
|
-
- [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)
|
|
383
|
-
- [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)
|
|
436
|
+
- [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)
|
|
437
|
+
- [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)
|
|
384
438
|
|
|
385
439
|
## Examples
|
|
386
440
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import {
|
|
5
5
|
createRouteAnnouncer,
|
|
6
6
|
createScrollRestoration,
|
|
7
|
+
createViewTransitions,
|
|
7
8
|
} from "./dom-utils";
|
|
8
9
|
import { setContext, untrack } from "svelte";
|
|
9
10
|
|
|
@@ -20,11 +21,13 @@
|
|
|
20
21
|
children,
|
|
21
22
|
announceNavigation,
|
|
22
23
|
scrollRestoration,
|
|
24
|
+
viewTransitions,
|
|
23
25
|
}: {
|
|
24
26
|
router: Router;
|
|
25
27
|
children: Snippet;
|
|
26
28
|
announceNavigation?: boolean;
|
|
27
29
|
scrollRestoration?: ScrollRestorationOptions;
|
|
30
|
+
viewTransitions?: boolean;
|
|
28
31
|
} = $props();
|
|
29
32
|
|
|
30
33
|
$effect(() => {
|
|
@@ -54,6 +57,12 @@
|
|
|
54
57
|
return () => sr.destroy();
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
$effect(() => {
|
|
61
|
+
if (!viewTransitions) return;
|
|
62
|
+
const vt = createViewTransitions(router);
|
|
63
|
+
return () => vt.destroy();
|
|
64
|
+
});
|
|
65
|
+
|
|
57
66
|
const navigator = getNavigator(router);
|
|
58
67
|
const source = createRouteSource(router);
|
|
59
68
|
const reactive = createReactiveSource(source);
|
|
@@ -6,6 +6,7 @@ type $$ComponentProps = {
|
|
|
6
6
|
children: Snippet;
|
|
7
7
|
announceNavigation?: boolean;
|
|
8
8
|
scrollRestoration?: ScrollRestorationOptions;
|
|
9
|
+
viewTransitions?: boolean;
|
|
9
10
|
};
|
|
10
11
|
declare const RouterProvider: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
12
|
type RouterProvider = ReturnType<typeof RouterProvider>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { State } from "@real-router/core";
|
|
2
|
+
export interface RouteEnterContext {
|
|
3
|
+
/** The route that was just activated. */
|
|
4
|
+
route: State;
|
|
5
|
+
/** The route that was active immediately before this navigation. */
|
|
6
|
+
previousRoute: State;
|
|
7
|
+
}
|
|
8
|
+
export type RouteEnterHandler = (context: RouteEnterContext) => void;
|
|
9
|
+
export interface UseRouteEnterOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Skip the handler when `route.name === previousRoute.name`
|
|
12
|
+
* (sort/filter/query-only navigations on the same route). Default:
|
|
13
|
+
* `true`. Symmetric with `useRouteExit`'s same-name option.
|
|
14
|
+
*/
|
|
15
|
+
skipSameRoute?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Fire `handler` once when the component mounts as a result of a
|
|
19
|
+
* navigation. Mirror of `useRouteExit` for the entry side.
|
|
20
|
+
*
|
|
21
|
+
* What this composable covers that an ad-hoc `$effect` + `useRoute()`
|
|
22
|
+
* doesn't:
|
|
23
|
+
*
|
|
24
|
+
* - **Skip-initial**: handler is skipped when there is no
|
|
25
|
+
* `route.transition.from` (i.e. first-load mount). Most consumers
|
|
26
|
+
* want to fire side effects only on real navigations, not on
|
|
27
|
+
* hydration.
|
|
28
|
+
* - **Same-route skip** (default): handler is skipped when
|
|
29
|
+
* `route.transition.from === route.name`. Sort/filter/query-only
|
|
30
|
+
* navigations re-run the effect (because the `route` reference
|
|
31
|
+
* changes), but they are not "entries" in the animation / analytics
|
|
32
|
+
* sense. Opt out with `skipSameRoute: false`.
|
|
33
|
+
* - **Mount-time `route` / `previousRoute` snapshot**: handler receives
|
|
34
|
+
* the values that were live at the moment of effect activation.
|
|
35
|
+
*
|
|
36
|
+
* **Handler reactivity (Svelte):** Svelte composables run **once** at
|
|
37
|
+
* component init; `handler` is captured in closure at the call site. To
|
|
38
|
+
* vary behavior over time, read `$state` / `$derived` values inside the
|
|
39
|
+
* handler body.
|
|
40
|
+
*
|
|
41
|
+
* @example Direction-aware entry animation
|
|
42
|
+
* ```svelte
|
|
43
|
+
* <script lang="ts">
|
|
44
|
+
* import { useRouteEnter } from "@real-router/svelte";
|
|
45
|
+
* let el: HTMLDivElement;
|
|
46
|
+
*
|
|
47
|
+
* useRouteEnter(({ route }) => {
|
|
48
|
+
* const direction = route.context.browser?.direction;
|
|
49
|
+
* el?.classList.add(
|
|
50
|
+
* direction === "back" ? "slide-from-left" : "slide-from-right",
|
|
51
|
+
* );
|
|
52
|
+
* });
|
|
53
|
+
* </script>
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @example Analytics page-enter event (skip-initial built-in)
|
|
57
|
+
* ```svelte
|
|
58
|
+
* <script lang="ts">
|
|
59
|
+
* useRouteEnter(({ route, previousRoute }) => {
|
|
60
|
+
* analytics.track("page_enter", {
|
|
61
|
+
* route: route.name,
|
|
62
|
+
* from: previousRoute.name,
|
|
63
|
+
* });
|
|
64
|
+
* });
|
|
65
|
+
* </script>
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export declare function useRouteEnter(handler: RouteEnterHandler, options?: UseRouteEnterOptions): void;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useRoute } from "./useRoute.svelte";
|
|
2
|
+
/**
|
|
3
|
+
* Fire `handler` once when the component mounts as a result of a
|
|
4
|
+
* navigation. Mirror of `useRouteExit` for the entry side.
|
|
5
|
+
*
|
|
6
|
+
* What this composable covers that an ad-hoc `$effect` + `useRoute()`
|
|
7
|
+
* doesn't:
|
|
8
|
+
*
|
|
9
|
+
* - **Skip-initial**: handler is skipped when there is no
|
|
10
|
+
* `route.transition.from` (i.e. first-load mount). Most consumers
|
|
11
|
+
* want to fire side effects only on real navigations, not on
|
|
12
|
+
* hydration.
|
|
13
|
+
* - **Same-route skip** (default): handler is skipped when
|
|
14
|
+
* `route.transition.from === route.name`. Sort/filter/query-only
|
|
15
|
+
* navigations re-run the effect (because the `route` reference
|
|
16
|
+
* changes), but they are not "entries" in the animation / analytics
|
|
17
|
+
* sense. Opt out with `skipSameRoute: false`.
|
|
18
|
+
* - **Mount-time `route` / `previousRoute` snapshot**: handler receives
|
|
19
|
+
* the values that were live at the moment of effect activation.
|
|
20
|
+
*
|
|
21
|
+
* **Handler reactivity (Svelte):** Svelte composables run **once** at
|
|
22
|
+
* component init; `handler` is captured in closure at the call site. To
|
|
23
|
+
* vary behavior over time, read `$state` / `$derived` values inside the
|
|
24
|
+
* handler body.
|
|
25
|
+
*
|
|
26
|
+
* @example Direction-aware entry animation
|
|
27
|
+
* ```svelte
|
|
28
|
+
* <script lang="ts">
|
|
29
|
+
* import { useRouteEnter } from "@real-router/svelte";
|
|
30
|
+
* let el: HTMLDivElement;
|
|
31
|
+
*
|
|
32
|
+
* useRouteEnter(({ route }) => {
|
|
33
|
+
* const direction = route.context.browser?.direction;
|
|
34
|
+
* el?.classList.add(
|
|
35
|
+
* direction === "back" ? "slide-from-left" : "slide-from-right",
|
|
36
|
+
* );
|
|
37
|
+
* });
|
|
38
|
+
* </script>
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @example Analytics page-enter event (skip-initial built-in)
|
|
42
|
+
* ```svelte
|
|
43
|
+
* <script lang="ts">
|
|
44
|
+
* useRouteEnter(({ route, previousRoute }) => {
|
|
45
|
+
* analytics.track("page_enter", {
|
|
46
|
+
* route: route.name,
|
|
47
|
+
* from: previousRoute.name,
|
|
48
|
+
* });
|
|
49
|
+
* });
|
|
50
|
+
* </script>
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function useRouteEnter(handler, options) {
|
|
54
|
+
const { route, previousRoute } = useRoute();
|
|
55
|
+
const skipSameRoute = options?.skipSameRoute ?? true;
|
|
56
|
+
let lastHandledRoute = null;
|
|
57
|
+
$effect(() => {
|
|
58
|
+
const currentRoute = route.current;
|
|
59
|
+
const prev = previousRoute.current;
|
|
60
|
+
// Early-exit guards, top-down:
|
|
61
|
+
//
|
|
62
|
+
// - **Defensive**: `route.current` may be undefined during SSR or
|
|
63
|
+
// pre-start hydration. Not testable from vitest, v8-ignored.
|
|
64
|
+
// - **Skip-initial**: `state.transition.from` is undefined only
|
|
65
|
+
// for the very first state committed by `router.start()`.
|
|
66
|
+
// - **Skip-same-route**: query-only navigations have
|
|
67
|
+
// `transition.from === route.name`. Opt-out via
|
|
68
|
+
// `skipSameRoute: false`.
|
|
69
|
+
// - **Defensive dedupe + missing `previousRoute`**: same `route`
|
|
70
|
+
// ref between `$effect` re-runs is unexpected (createSubscriber
|
|
71
|
+
// only fires on real reference changes); `!prev` is unreachable
|
|
72
|
+
// once `transition.from` is set (core populates them together).
|
|
73
|
+
// Both kept for parity with React; v8-ignored.
|
|
74
|
+
/* v8 ignore start */
|
|
75
|
+
if (!currentRoute) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
/* v8 ignore stop */
|
|
79
|
+
if (!currentRoute.transition.from) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (skipSameRoute && currentRoute.transition.from === currentRoute.name) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
/* v8 ignore start */
|
|
86
|
+
if (lastHandledRoute === currentRoute || !prev) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
/* v8 ignore stop */
|
|
90
|
+
lastHandledRoute = currentRoute;
|
|
91
|
+
handler({ route: currentRoute, previousRoute: prev });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { State } from "@real-router/core";
|
|
2
|
+
export interface RouteExitContext {
|
|
3
|
+
/** The route being left. */
|
|
4
|
+
route: State;
|
|
5
|
+
/** The route being navigated to. */
|
|
6
|
+
nextRoute: State;
|
|
7
|
+
/**
|
|
8
|
+
* AbortSignal that fires when this navigation is superseded by a later
|
|
9
|
+
* one (rapid clicks). Already filtered: when the handler runs,
|
|
10
|
+
* `signal.aborted` is guaranteed to be `false`. Use
|
|
11
|
+
* `signal.addEventListener("abort", cleanup, { once: true })` for
|
|
12
|
+
* cleanup that must run on cancellation.
|
|
13
|
+
*/
|
|
14
|
+
signal: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
export interface UseRouteExitOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Skip the handler when `route.name === nextRoute.name`
|
|
19
|
+
* (sort/filter/query-only navigations on the same route). Default:
|
|
20
|
+
* `true`.
|
|
21
|
+
*/
|
|
22
|
+
skipSameRoute?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export type RouteExitHandler = (context: RouteExitContext) => void | Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Subscribe to the router's leave-window with the universal guards baked
|
|
27
|
+
* in. Wraps `router.subscribeLeave` so consumers don't repeat the same
|
|
28
|
+
* boilerplate every time:
|
|
29
|
+
*
|
|
30
|
+
* - **Reentrant abort pre-check**: if `signal.aborted` is already `true`
|
|
31
|
+
* when the handler would run (rapid navigation superseded a slower
|
|
32
|
+
* one), the handler is skipped entirely.
|
|
33
|
+
* - **Same-route skip**: by default, `route.name === nextRoute.name`
|
|
34
|
+
* short-circuits the handler — query-only navigations skip the work.
|
|
35
|
+
* Opt out with `skipSameRoute: false`.
|
|
36
|
+
*
|
|
37
|
+
* Cleanup is bound to the component via `onDestroy`. Must be called
|
|
38
|
+
* during component initialization (synchronous in `<script>`).
|
|
39
|
+
*
|
|
40
|
+
* If the handler returns a Promise, the router blocks on it. If the
|
|
41
|
+
* Promise resolves, navigation proceeds. If it rejects, the router emits
|
|
42
|
+
* `TRANSITION_CANCELLED`.
|
|
43
|
+
*
|
|
44
|
+
* **Handler reactivity (Svelte):** Svelte composables run **once** at
|
|
45
|
+
* component init; `handler` is captured in closure at the call site. To
|
|
46
|
+
* vary behavior over time, read `$state` / `$derived` values inside the
|
|
47
|
+
* handler body — do not rely on swapping the handler reference.
|
|
48
|
+
*
|
|
49
|
+
* @example Animation
|
|
50
|
+
* ```svelte
|
|
51
|
+
* <script lang="ts">
|
|
52
|
+
* import { useRouteExit } from "@real-router/svelte";
|
|
53
|
+
* let el: HTMLDivElement;
|
|
54
|
+
*
|
|
55
|
+
* useRouteExit(async ({ signal }) => {
|
|
56
|
+
* if (!el) return;
|
|
57
|
+
* el.classList.add("fade-out");
|
|
58
|
+
* const cleanup = () => el.classList.remove("fade-out");
|
|
59
|
+
* signal.addEventListener("abort", cleanup, { once: true });
|
|
60
|
+
* try {
|
|
61
|
+
* el.getBoundingClientRect();
|
|
62
|
+
* await Promise.allSettled(el.getAnimations().map((a) => a.finished));
|
|
63
|
+
* } finally {
|
|
64
|
+
* cleanup();
|
|
65
|
+
* }
|
|
66
|
+
* });
|
|
67
|
+
* </script>
|
|
68
|
+
*
|
|
69
|
+
* <div bind:this={el}>...</div>
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* @example Reading rich transition metadata via `nextRoute.transition`
|
|
73
|
+
* ```svelte
|
|
74
|
+
* <script lang="ts">
|
|
75
|
+
* useRouteExit(({ nextRoute }) => {
|
|
76
|
+
* if (nextRoute.transition.segments.deactivated.includes("products")) {
|
|
77
|
+
* productCache.clear();
|
|
78
|
+
* }
|
|
79
|
+
* });
|
|
80
|
+
* </script>
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export declare function useRouteExit(handler: RouteExitHandler, options?: UseRouteExitOptions): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { onDestroy } from "svelte";
|
|
2
|
+
import { useRouter } from "./useRouter.svelte";
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe to the router's leave-window with the universal guards baked
|
|
5
|
+
* in. Wraps `router.subscribeLeave` so consumers don't repeat the same
|
|
6
|
+
* boilerplate every time:
|
|
7
|
+
*
|
|
8
|
+
* - **Reentrant abort pre-check**: if `signal.aborted` is already `true`
|
|
9
|
+
* when the handler would run (rapid navigation superseded a slower
|
|
10
|
+
* one), the handler is skipped entirely.
|
|
11
|
+
* - **Same-route skip**: by default, `route.name === nextRoute.name`
|
|
12
|
+
* short-circuits the handler — query-only navigations skip the work.
|
|
13
|
+
* Opt out with `skipSameRoute: false`.
|
|
14
|
+
*
|
|
15
|
+
* Cleanup is bound to the component via `onDestroy`. Must be called
|
|
16
|
+
* during component initialization (synchronous in `<script>`).
|
|
17
|
+
*
|
|
18
|
+
* If the handler returns a Promise, the router blocks on it. If the
|
|
19
|
+
* Promise resolves, navigation proceeds. If it rejects, the router emits
|
|
20
|
+
* `TRANSITION_CANCELLED`.
|
|
21
|
+
*
|
|
22
|
+
* **Handler reactivity (Svelte):** Svelte composables run **once** at
|
|
23
|
+
* component init; `handler` is captured in closure at the call site. To
|
|
24
|
+
* vary behavior over time, read `$state` / `$derived` values inside the
|
|
25
|
+
* handler body — do not rely on swapping the handler reference.
|
|
26
|
+
*
|
|
27
|
+
* @example Animation
|
|
28
|
+
* ```svelte
|
|
29
|
+
* <script lang="ts">
|
|
30
|
+
* import { useRouteExit } from "@real-router/svelte";
|
|
31
|
+
* let el: HTMLDivElement;
|
|
32
|
+
*
|
|
33
|
+
* useRouteExit(async ({ signal }) => {
|
|
34
|
+
* if (!el) return;
|
|
35
|
+
* el.classList.add("fade-out");
|
|
36
|
+
* const cleanup = () => el.classList.remove("fade-out");
|
|
37
|
+
* signal.addEventListener("abort", cleanup, { once: true });
|
|
38
|
+
* try {
|
|
39
|
+
* el.getBoundingClientRect();
|
|
40
|
+
* await Promise.allSettled(el.getAnimations().map((a) => a.finished));
|
|
41
|
+
* } finally {
|
|
42
|
+
* cleanup();
|
|
43
|
+
* }
|
|
44
|
+
* });
|
|
45
|
+
* </script>
|
|
46
|
+
*
|
|
47
|
+
* <div bind:this={el}>...</div>
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @example Reading rich transition metadata via `nextRoute.transition`
|
|
51
|
+
* ```svelte
|
|
52
|
+
* <script lang="ts">
|
|
53
|
+
* useRouteExit(({ nextRoute }) => {
|
|
54
|
+
* if (nextRoute.transition.segments.deactivated.includes("products")) {
|
|
55
|
+
* productCache.clear();
|
|
56
|
+
* }
|
|
57
|
+
* });
|
|
58
|
+
* </script>
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function useRouteExit(handler, options) {
|
|
62
|
+
const router = useRouter();
|
|
63
|
+
const skipSameRoute = options?.skipSameRoute ?? true;
|
|
64
|
+
const off = router.subscribeLeave(({ route, nextRoute, signal }) => {
|
|
65
|
+
if (skipSameRoute && route.name === nextRoute.name) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (signal.aborted) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
return handler({ route, nextRoute, signal });
|
|
72
|
+
});
|
|
73
|
+
onDestroy(off);
|
|
74
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Router } from "@real-router/core";
|
|
2
|
+
export interface DirectionTracker {
|
|
3
|
+
destroy: () => void;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Track navigation direction (forward / back) and write it to
|
|
7
|
+
* `<html data-nav-direction>` on every leave. CSS / JS readers consume
|
|
8
|
+
* the attribute via `html[data-nav-direction="back"]` selectors or
|
|
9
|
+
* `document.documentElement.dataset.navDirection`.
|
|
10
|
+
*
|
|
11
|
+
* Mechanism-agnostic — works identically whether downstream UI uses CSS
|
|
12
|
+
* `@keyframes`, View Transitions pseudo-elements, or library state
|
|
13
|
+
* (motion's `motion.div initial={{ x: ... }}`).
|
|
14
|
+
*
|
|
15
|
+
* Implementation:
|
|
16
|
+
* - On install, set `data-nav-direction="forward"` baseline.
|
|
17
|
+
* - Attach a `popstate` listener that flips an internal flag to
|
|
18
|
+
* `true`. Browser back/forward navigation triggers popstate; user
|
|
19
|
+
* clicks on `<Link>` / programmatic `router.navigate(...)` do not.
|
|
20
|
+
* - On every `subscribeLeave`, write
|
|
21
|
+
* `popstateFlag ? "back" : "forward"` and reset the flag.
|
|
22
|
+
*
|
|
23
|
+
* Returns `{ destroy }` to clean up the listener and clear the dataset
|
|
24
|
+
* attribute.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createDirectionTracker(router: Router): DirectionTracker;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const NOOP_INSTANCE = Object.freeze({
|
|
2
|
+
destroy: () => {
|
|
3
|
+
/* no-op */
|
|
4
|
+
},
|
|
5
|
+
});
|
|
6
|
+
/**
|
|
7
|
+
* Track navigation direction (forward / back) and write it to
|
|
8
|
+
* `<html data-nav-direction>` on every leave. CSS / JS readers consume
|
|
9
|
+
* the attribute via `html[data-nav-direction="back"]` selectors or
|
|
10
|
+
* `document.documentElement.dataset.navDirection`.
|
|
11
|
+
*
|
|
12
|
+
* Mechanism-agnostic — works identically whether downstream UI uses CSS
|
|
13
|
+
* `@keyframes`, View Transitions pseudo-elements, or library state
|
|
14
|
+
* (motion's `motion.div initial={{ x: ... }}`).
|
|
15
|
+
*
|
|
16
|
+
* Implementation:
|
|
17
|
+
* - On install, set `data-nav-direction="forward"` baseline.
|
|
18
|
+
* - Attach a `popstate` listener that flips an internal flag to
|
|
19
|
+
* `true`. Browser back/forward navigation triggers popstate; user
|
|
20
|
+
* clicks on `<Link>` / programmatic `router.navigate(...)` do not.
|
|
21
|
+
* - On every `subscribeLeave`, write
|
|
22
|
+
* `popstateFlag ? "back" : "forward"` and reset the flag.
|
|
23
|
+
*
|
|
24
|
+
* Returns `{ destroy }` to clean up the listener and clear the dataset
|
|
25
|
+
* attribute.
|
|
26
|
+
*/
|
|
27
|
+
export function createDirectionTracker(router) {
|
|
28
|
+
if (typeof document === "undefined") {
|
|
29
|
+
return NOOP_INSTANCE;
|
|
30
|
+
}
|
|
31
|
+
let popstateFlag = false;
|
|
32
|
+
document.documentElement.dataset.navDirection = "forward";
|
|
33
|
+
const onPopstate = () => {
|
|
34
|
+
popstateFlag = true;
|
|
35
|
+
};
|
|
36
|
+
// IMPORTANT — listener-ordering: `popstate` fires on `window`, which
|
|
37
|
+
// has no DOM descendants, so capture phase is moot. Listeners are
|
|
38
|
+
// dispatched in registration order. To beat the browser-plugin's own
|
|
39
|
+
// popstate handler, this tracker must be installed **before**
|
|
40
|
+
// `router.usePlugin(browserPluginFactory())` in user code. Otherwise
|
|
41
|
+
// the plugin's handler runs first and synchronously fires
|
|
42
|
+
// `subscribeLeave` while `popstateFlag` is still `false`.
|
|
43
|
+
globalThis.addEventListener("popstate", onPopstate);
|
|
44
|
+
const offLeave = router.subscribeLeave(() => {
|
|
45
|
+
document.documentElement.dataset.navDirection = popstateFlag
|
|
46
|
+
? "back"
|
|
47
|
+
: "forward";
|
|
48
|
+
popstateFlag = false;
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
destroy: () => {
|
|
52
|
+
offLeave();
|
|
53
|
+
globalThis.removeEventListener("popstate", onPopstate);
|
|
54
|
+
delete document.documentElement.dataset.navDirection;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
export { createDirectionTracker } from "./direction-tracker.js";
|
|
1
2
|
export { createRouteAnnouncer } from "./route-announcer.js";
|
|
2
3
|
export { createScrollRestoration } from "./scroll-restore.js";
|
|
4
|
+
export { createViewTransitions } from "./view-transitions.js";
|
|
3
5
|
export { shouldNavigate, buildHref, buildActiveClassName, shallowEqual, applyLinkA11y, } from "./link-utils.js";
|
|
4
6
|
export type { RouteAnnouncerOptions } from "./route-announcer.js";
|
|
5
7
|
export type { ScrollRestorationOptions, ScrollRestorationMode, } from "./scroll-restore.js";
|
|
8
|
+
export type { DirectionTracker } from "./direction-tracker.js";
|
|
9
|
+
export type { ViewTransitions } from "./view-transitions.js";
|
package/dist/dom-utils/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { createDirectionTracker } from "./direction-tracker.js";
|
|
1
2
|
export { createRouteAnnouncer } from "./route-announcer.js";
|
|
2
3
|
export { createScrollRestoration } from "./scroll-restore.js";
|
|
4
|
+
export { createViewTransitions } from "./view-transitions.js";
|
|
3
5
|
export { shouldNavigate, buildHref, buildActiveClassName, shallowEqual, applyLinkA11y, } from "./link-utils.js";
|
|
@@ -94,7 +94,7 @@ function resolveText(route, prefix, getCustomText, h1) {
|
|
|
94
94
|
if (getCustomText) {
|
|
95
95
|
return getCustomText(route);
|
|
96
96
|
}
|
|
97
|
-
const h1Text = h1?.textContent
|
|
97
|
+
const h1Text = (h1?.textContent ?? "").trim();
|
|
98
98
|
const routeName = route.name.startsWith(INTERNAL_ROUTE_PREFIX)
|
|
99
99
|
? ""
|
|
100
100
|
: route.name;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const NOOP_INSTANCE = Object.freeze({
|
|
2
|
+
destroy: () => {
|
|
3
|
+
/* no-op */
|
|
4
|
+
},
|
|
5
|
+
});
|
|
6
|
+
export function createViewTransitions(router) {
|
|
7
|
+
if (typeof document === "undefined" ||
|
|
8
|
+
typeof document.startViewTransition !== "function") {
|
|
9
|
+
return NOOP_INSTANCE;
|
|
10
|
+
}
|
|
11
|
+
let closeVT = null;
|
|
12
|
+
let currentVT = null;
|
|
13
|
+
// Tracks whether TRANSITION_SUCCESS fired for the current leave. Used to
|
|
14
|
+
// distinguish "benign cleanup abort" (router's async path aborts its own
|
|
15
|
+
// controller in a finally block after successful navigation) from "real
|
|
16
|
+
// cancellation" (concurrent navigate, guard rejection, dispose).
|
|
17
|
+
let successFired = false;
|
|
18
|
+
const resolveAndClear = () => {
|
|
19
|
+
closeVT?.();
|
|
20
|
+
closeVT = null;
|
|
21
|
+
};
|
|
22
|
+
const offLeave = router.subscribeLeave(({ signal }) => {
|
|
23
|
+
// Reentrant abort: signal already aborted when we're called. Open no VT
|
|
24
|
+
// — router will fall through to TRANSITION_CANCELLED via isCurrentNav()
|
|
25
|
+
// after leave resolves. addEventListener("abort", ...) does not re-fire
|
|
26
|
+
// for past events, so skipping startViewTransition is the safe path.
|
|
27
|
+
if (signal.aborted) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
successFired = false;
|
|
31
|
+
resolveAndClear();
|
|
32
|
+
// Return a Promise so the router awaits until the browser invokes
|
|
33
|
+
// updateCallback. This ensures old DOM snapshot is captured BEFORE the
|
|
34
|
+
// router commits the new state — giving correct exit→state→entry
|
|
35
|
+
// ordering (vs fire-and-forget, where URL changes before VT captures).
|
|
36
|
+
return new Promise((resolveLeave) => {
|
|
37
|
+
// Capture the resolver synchronously BEFORE startViewTransition() is
|
|
38
|
+
// called. The browser invokes updateCallback in a later task, but
|
|
39
|
+
// router.subscribe (TRANSITION_SUCCESS) can fire before that. If we
|
|
40
|
+
// captured `resolve` inside the callback, subscribe would see closeVT
|
|
41
|
+
// still null and skip resolving — the deferred would hang for 4s
|
|
42
|
+
// until the VT API aborts with TimeoutError.
|
|
43
|
+
const deferred = new Promise((resolve) => {
|
|
44
|
+
closeVT = resolve;
|
|
45
|
+
});
|
|
46
|
+
signal.addEventListener("abort", () => {
|
|
47
|
+
if (successFired) {
|
|
48
|
+
// Router's async path (#finishAsyncNavigation) aborts its own
|
|
49
|
+
// controller in a finally block AFTER completeTransition (and
|
|
50
|
+
// thus AFTER subscribe fired). This is cleanup, not
|
|
51
|
+
// cancellation — VT is progressing normally, do nothing.
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// Real cancellation (concurrent navigate, dispose). Resolve the
|
|
55
|
+
// deferred so updateCallback can complete, skip the VT so no
|
|
56
|
+
// stale animation leaks, and unblock the router if the abort
|
|
57
|
+
// fires before updateCallback was invoked.
|
|
58
|
+
resolveAndClear();
|
|
59
|
+
currentVT?.skipTransition?.();
|
|
60
|
+
resolveLeave();
|
|
61
|
+
}, { once: true });
|
|
62
|
+
try {
|
|
63
|
+
currentVT = document.startViewTransition(() => {
|
|
64
|
+
// Resolving here unblocks the router at the moment the browser
|
|
65
|
+
// enters updateCallback — by spec, old DOM snapshot is captured
|
|
66
|
+
// before this callback runs. Router now proceeds through
|
|
67
|
+
// activation guards and setState; the VT animation waits on
|
|
68
|
+
// `deferred`, which is resolved from router.subscribe after a
|
|
69
|
+
// task-queue tick (see NOTE on setTimeout below).
|
|
70
|
+
resolveLeave();
|
|
71
|
+
return deferred;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Defensive: spec says startViewTransition doesn't throw under
|
|
76
|
+
// normal conditions, but Chromium has had edge cases (detached
|
|
77
|
+
// document, extension interference). Clean up and unblock router.
|
|
78
|
+
resolveAndClear();
|
|
79
|
+
resolveLeave();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
const offSuccess = router.subscribe(() => {
|
|
84
|
+
const resolver = closeVT;
|
|
85
|
+
successFired = true;
|
|
86
|
+
closeVT = null;
|
|
87
|
+
if (resolver === null) {
|
|
88
|
+
currentVT = null;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// CRITICAL: CANNOT use requestAnimationFrame here. When the router
|
|
92
|
+
// takes the async path (leave returned a Promise), subscribe fires
|
|
93
|
+
// AFTER the browser has already transitioned VT into the
|
|
94
|
+
// "update-callback-called" phase. In that phase Chromium sets
|
|
95
|
+
// rendering suppression to true, which ALSO blocks rAF callbacks.
|
|
96
|
+
// rAF would never fire → deferred never resolves → browser aborts
|
|
97
|
+
// vt.ready with TimeoutError after 4s (observed in Chromium).
|
|
98
|
+
//
|
|
99
|
+
// setTimeout runs on the task queue independent of the rendering
|
|
100
|
+
// pipeline, so it fires regardless of suppression. React's scheduler
|
|
101
|
+
// uses MessageChannel tasks, which are queued before our setTimeout,
|
|
102
|
+
// so the new DOM is committed by the time our callback runs.
|
|
103
|
+
setTimeout(() => {
|
|
104
|
+
resolver();
|
|
105
|
+
currentVT = null;
|
|
106
|
+
}, 0);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
destroy: () => {
|
|
111
|
+
offLeave();
|
|
112
|
+
offSuccess();
|
|
113
|
+
currentVT?.skipTransition?.();
|
|
114
|
+
currentVT = null;
|
|
115
|
+
resolveAndClear();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,10 +9,14 @@ export { useRouteUtils } from "./composables/useRouteUtils.svelte";
|
|
|
9
9
|
export { useRoute } from "./composables/useRoute.svelte";
|
|
10
10
|
export { useRouteNode } from "./composables/useRouteNode.svelte";
|
|
11
11
|
export { useRouterTransition } from "./composables/useRouterTransition.svelte";
|
|
12
|
+
export { useRouteExit } from "./composables/useRouteExit.svelte";
|
|
13
|
+
export { useRouteEnter } from "./composables/useRouteEnter.svelte";
|
|
12
14
|
export { createLinkAction } from "./actions/link.svelte";
|
|
13
15
|
export type { LinkActionParams } from "./actions/link.svelte";
|
|
14
16
|
export { default as RouterProvider } from "./RouterProvider.svelte";
|
|
15
17
|
export { ROUTER_KEY, NAVIGATOR_KEY, ROUTE_KEY } from "./context";
|
|
16
18
|
export type { LinkProps, RouteContext } from "./types";
|
|
19
|
+
export type { RouteExitContext, RouteExitHandler, UseRouteExitOptions, } from "./composables/useRouteExit.svelte";
|
|
20
|
+
export type { RouteEnterContext, RouteEnterHandler, UseRouteEnterOptions, } from "./composables/useRouteEnter.svelte";
|
|
17
21
|
export type { Navigator } from "@real-router/core";
|
|
18
22
|
export type { RouterTransitionSnapshot, RouterErrorSnapshot, } from "@real-router/sources";
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,8 @@ export { useRouteUtils } from "./composables/useRouteUtils.svelte";
|
|
|
12
12
|
export { useRoute } from "./composables/useRoute.svelte";
|
|
13
13
|
export { useRouteNode } from "./composables/useRouteNode.svelte";
|
|
14
14
|
export { useRouterTransition } from "./composables/useRouterTransition.svelte";
|
|
15
|
+
export { useRouteExit } from "./composables/useRouteExit.svelte";
|
|
16
|
+
export { useRouteEnter } from "./composables/useRouteEnter.svelte";
|
|
15
17
|
// Actions
|
|
16
18
|
export { createLinkAction } from "./actions/link.svelte";
|
|
17
19
|
// Context
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@real-router/svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Svelte 5 integration for Real-Router",
|
|
6
6
|
"svelte": "./dist/index.js",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"license": "MIT",
|
|
45
45
|
"sideEffects": false,
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@real-router/core": "^0.50.
|
|
47
|
+
"@real-router/core": "^0.50.2",
|
|
48
48
|
"@real-router/route-utils": "^0.2.1",
|
|
49
49
|
"@real-router/sources": "^0.7.2"
|
|
50
50
|
},
|
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
"@testing-library/jest-dom": "6.9.1",
|
|
55
55
|
"@testing-library/svelte": "5.3.1",
|
|
56
56
|
"@testing-library/user-event": "14.6.1",
|
|
57
|
-
"eslint-plugin-svelte": "3.
|
|
57
|
+
"eslint-plugin-svelte": "3.17.1",
|
|
58
58
|
"svelte": "5.54.0",
|
|
59
59
|
"svelte-check": "4.4.5",
|
|
60
60
|
"svelte-eslint-parser": "1.6.0",
|
|
61
|
-
"@real-router/browser-plugin": "^0.
|
|
61
|
+
"@real-router/browser-plugin": "^0.16.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"svelte": ">=5.7.0"
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import {
|
|
5
5
|
createRouteAnnouncer,
|
|
6
6
|
createScrollRestoration,
|
|
7
|
+
createViewTransitions,
|
|
7
8
|
} from "./dom-utils";
|
|
8
9
|
import { setContext, untrack } from "svelte";
|
|
9
10
|
|
|
@@ -20,11 +21,13 @@
|
|
|
20
21
|
children,
|
|
21
22
|
announceNavigation,
|
|
22
23
|
scrollRestoration,
|
|
24
|
+
viewTransitions,
|
|
23
25
|
}: {
|
|
24
26
|
router: Router;
|
|
25
27
|
children: Snippet;
|
|
26
28
|
announceNavigation?: boolean;
|
|
27
29
|
scrollRestoration?: ScrollRestorationOptions;
|
|
30
|
+
viewTransitions?: boolean;
|
|
28
31
|
} = $props();
|
|
29
32
|
|
|
30
33
|
$effect(() => {
|
|
@@ -54,6 +57,12 @@
|
|
|
54
57
|
return () => sr.destroy();
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
$effect(() => {
|
|
61
|
+
if (!viewTransitions) return;
|
|
62
|
+
const vt = createViewTransitions(router);
|
|
63
|
+
return () => vt.destroy();
|
|
64
|
+
});
|
|
65
|
+
|
|
57
66
|
const navigator = getNavigator(router);
|
|
58
67
|
const source = createRouteSource(router);
|
|
59
68
|
const reactive = createReactiveSource(source);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useRoute } from "./useRoute.svelte";
|
|
2
|
+
|
|
3
|
+
import type { State } from "@real-router/core";
|
|
4
|
+
|
|
5
|
+
export interface RouteEnterContext {
|
|
6
|
+
/** The route that was just activated. */
|
|
7
|
+
route: State;
|
|
8
|
+
/** The route that was active immediately before this navigation. */
|
|
9
|
+
previousRoute: State;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type RouteEnterHandler = (context: RouteEnterContext) => void;
|
|
13
|
+
|
|
14
|
+
export interface UseRouteEnterOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Skip the handler when `route.name === previousRoute.name`
|
|
17
|
+
* (sort/filter/query-only navigations on the same route). Default:
|
|
18
|
+
* `true`. Symmetric with `useRouteExit`'s same-name option.
|
|
19
|
+
*/
|
|
20
|
+
skipSameRoute?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fire `handler` once when the component mounts as a result of a
|
|
25
|
+
* navigation. Mirror of `useRouteExit` for the entry side.
|
|
26
|
+
*
|
|
27
|
+
* What this composable covers that an ad-hoc `$effect` + `useRoute()`
|
|
28
|
+
* doesn't:
|
|
29
|
+
*
|
|
30
|
+
* - **Skip-initial**: handler is skipped when there is no
|
|
31
|
+
* `route.transition.from` (i.e. first-load mount). Most consumers
|
|
32
|
+
* want to fire side effects only on real navigations, not on
|
|
33
|
+
* hydration.
|
|
34
|
+
* - **Same-route skip** (default): handler is skipped when
|
|
35
|
+
* `route.transition.from === route.name`. Sort/filter/query-only
|
|
36
|
+
* navigations re-run the effect (because the `route` reference
|
|
37
|
+
* changes), but they are not "entries" in the animation / analytics
|
|
38
|
+
* sense. Opt out with `skipSameRoute: false`.
|
|
39
|
+
* - **Mount-time `route` / `previousRoute` snapshot**: handler receives
|
|
40
|
+
* the values that were live at the moment of effect activation.
|
|
41
|
+
*
|
|
42
|
+
* **Handler reactivity (Svelte):** Svelte composables run **once** at
|
|
43
|
+
* component init; `handler` is captured in closure at the call site. To
|
|
44
|
+
* vary behavior over time, read `$state` / `$derived` values inside the
|
|
45
|
+
* handler body.
|
|
46
|
+
*
|
|
47
|
+
* @example Direction-aware entry animation
|
|
48
|
+
* ```svelte
|
|
49
|
+
* <script lang="ts">
|
|
50
|
+
* import { useRouteEnter } from "@real-router/svelte";
|
|
51
|
+
* let el: HTMLDivElement;
|
|
52
|
+
*
|
|
53
|
+
* useRouteEnter(({ route }) => {
|
|
54
|
+
* const direction = route.context.browser?.direction;
|
|
55
|
+
* el?.classList.add(
|
|
56
|
+
* direction === "back" ? "slide-from-left" : "slide-from-right",
|
|
57
|
+
* );
|
|
58
|
+
* });
|
|
59
|
+
* </script>
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* @example Analytics page-enter event (skip-initial built-in)
|
|
63
|
+
* ```svelte
|
|
64
|
+
* <script lang="ts">
|
|
65
|
+
* useRouteEnter(({ route, previousRoute }) => {
|
|
66
|
+
* analytics.track("page_enter", {
|
|
67
|
+
* route: route.name,
|
|
68
|
+
* from: previousRoute.name,
|
|
69
|
+
* });
|
|
70
|
+
* });
|
|
71
|
+
* </script>
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function useRouteEnter(
|
|
75
|
+
handler: RouteEnterHandler,
|
|
76
|
+
options?: UseRouteEnterOptions,
|
|
77
|
+
): void {
|
|
78
|
+
const { route, previousRoute } = useRoute();
|
|
79
|
+
const skipSameRoute = options?.skipSameRoute ?? true;
|
|
80
|
+
let lastHandledRoute: State | null = null;
|
|
81
|
+
|
|
82
|
+
$effect(() => {
|
|
83
|
+
const currentRoute = route.current;
|
|
84
|
+
const prev = previousRoute.current;
|
|
85
|
+
|
|
86
|
+
// Early-exit guards, top-down:
|
|
87
|
+
//
|
|
88
|
+
// - **Defensive**: `route.current` may be undefined during SSR or
|
|
89
|
+
// pre-start hydration. Not testable from vitest, v8-ignored.
|
|
90
|
+
// - **Skip-initial**: `state.transition.from` is undefined only
|
|
91
|
+
// for the very first state committed by `router.start()`.
|
|
92
|
+
// - **Skip-same-route**: query-only navigations have
|
|
93
|
+
// `transition.from === route.name`. Opt-out via
|
|
94
|
+
// `skipSameRoute: false`.
|
|
95
|
+
// - **Defensive dedupe + missing `previousRoute`**: same `route`
|
|
96
|
+
// ref between `$effect` re-runs is unexpected (createSubscriber
|
|
97
|
+
// only fires on real reference changes); `!prev` is unreachable
|
|
98
|
+
// once `transition.from` is set (core populates them together).
|
|
99
|
+
// Both kept for parity with React; v8-ignored.
|
|
100
|
+
/* v8 ignore start */
|
|
101
|
+
if (!currentRoute) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
/* v8 ignore stop */
|
|
105
|
+
if (!currentRoute.transition.from) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (skipSameRoute && currentRoute.transition.from === currentRoute.name) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
/* v8 ignore start */
|
|
112
|
+
if (lastHandledRoute === currentRoute || !prev) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
/* v8 ignore stop */
|
|
116
|
+
|
|
117
|
+
lastHandledRoute = currentRoute;
|
|
118
|
+
handler({ route: currentRoute, previousRoute: prev });
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { onDestroy } from "svelte";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "./useRouter.svelte";
|
|
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 component via `onDestroy`. Must be called
|
|
48
|
+
* during component initialization (synchronous in `<script>`).
|
|
49
|
+
*
|
|
50
|
+
* If the handler returns a Promise, the router blocks on it. If the
|
|
51
|
+
* Promise resolves, navigation proceeds. If it rejects, the router emits
|
|
52
|
+
* `TRANSITION_CANCELLED`.
|
|
53
|
+
*
|
|
54
|
+
* **Handler reactivity (Svelte):** Svelte composables run **once** at
|
|
55
|
+
* component init; `handler` is captured in closure at the call site. To
|
|
56
|
+
* vary behavior over time, read `$state` / `$derived` values inside the
|
|
57
|
+
* handler body — do not rely on swapping the handler reference.
|
|
58
|
+
*
|
|
59
|
+
* @example Animation
|
|
60
|
+
* ```svelte
|
|
61
|
+
* <script lang="ts">
|
|
62
|
+
* import { useRouteExit } from "@real-router/svelte";
|
|
63
|
+
* let el: HTMLDivElement;
|
|
64
|
+
*
|
|
65
|
+
* useRouteExit(async ({ signal }) => {
|
|
66
|
+
* if (!el) return;
|
|
67
|
+
* el.classList.add("fade-out");
|
|
68
|
+
* const cleanup = () => el.classList.remove("fade-out");
|
|
69
|
+
* signal.addEventListener("abort", cleanup, { once: true });
|
|
70
|
+
* try {
|
|
71
|
+
* el.getBoundingClientRect();
|
|
72
|
+
* await Promise.allSettled(el.getAnimations().map((a) => a.finished));
|
|
73
|
+
* } finally {
|
|
74
|
+
* cleanup();
|
|
75
|
+
* }
|
|
76
|
+
* });
|
|
77
|
+
* </script>
|
|
78
|
+
*
|
|
79
|
+
* <div bind:this={el}>...</div>
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @example Reading rich transition metadata via `nextRoute.transition`
|
|
83
|
+
* ```svelte
|
|
84
|
+
* <script lang="ts">
|
|
85
|
+
* useRouteExit(({ nextRoute }) => {
|
|
86
|
+
* if (nextRoute.transition.segments.deactivated.includes("products")) {
|
|
87
|
+
* productCache.clear();
|
|
88
|
+
* }
|
|
89
|
+
* });
|
|
90
|
+
* </script>
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function useRouteExit(
|
|
94
|
+
handler: RouteExitHandler,
|
|
95
|
+
options?: UseRouteExitOptions,
|
|
96
|
+
): void {
|
|
97
|
+
const router = useRouter();
|
|
98
|
+
const skipSameRoute = options?.skipSameRoute ?? true;
|
|
99
|
+
|
|
100
|
+
const off = router.subscribeLeave(({ route, nextRoute, signal }) => {
|
|
101
|
+
if (skipSameRoute && route.name === nextRoute.name) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (signal.aborted) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return handler({ route, nextRoute, signal });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
onDestroy(off);
|
|
113
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,10 @@ export { useRouteNode } from "./composables/useRouteNode.svelte";
|
|
|
23
23
|
|
|
24
24
|
export { useRouterTransition } from "./composables/useRouterTransition.svelte";
|
|
25
25
|
|
|
26
|
+
export { useRouteExit } from "./composables/useRouteExit.svelte";
|
|
27
|
+
|
|
28
|
+
export { useRouteEnter } from "./composables/useRouteEnter.svelte";
|
|
29
|
+
|
|
26
30
|
// Actions
|
|
27
31
|
export { createLinkAction } from "./actions/link.svelte";
|
|
28
32
|
|
|
@@ -36,6 +40,18 @@ export { ROUTER_KEY, NAVIGATOR_KEY, ROUTE_KEY } from "./context";
|
|
|
36
40
|
// Types
|
|
37
41
|
export type { LinkProps, RouteContext } from "./types";
|
|
38
42
|
|
|
43
|
+
export type {
|
|
44
|
+
RouteExitContext,
|
|
45
|
+
RouteExitHandler,
|
|
46
|
+
UseRouteExitOptions,
|
|
47
|
+
} from "./composables/useRouteExit.svelte";
|
|
48
|
+
|
|
49
|
+
export type {
|
|
50
|
+
RouteEnterContext,
|
|
51
|
+
RouteEnterHandler,
|
|
52
|
+
UseRouteEnterOptions,
|
|
53
|
+
} from "./composables/useRouteEnter.svelte";
|
|
54
|
+
|
|
39
55
|
export type { Navigator } from "@real-router/core";
|
|
40
56
|
|
|
41
57
|
export type {
|