@real-router/svelte 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -183,11 +183,21 @@ Navigation link with automatic active state detection. Uses `$derived` for href
183
183
  | `activeClassName` | `string` | `"active"` | Class added when route is active |
184
184
  | `activeStrict` | `boolean` | `false` | Exact match only (no ancestor matching) |
185
185
  | `ignoreQueryParams` | `boolean` | `true` | Query params don't affect active state |
186
+ | `hash` | `string` | `undefined` | URL fragment (decoded). Tri-state: undefined preserves, `""` clears, value sets. (#532) |
186
187
  | `target` | `string` | `undefined` | Link target (`_blank`, etc.) |
187
188
  | `onclick` | `(evt: MouseEvent) => void` | `undefined` | Custom click handler. Runs **before** the navigation logic — call `evt.preventDefault()` to suppress navigation. |
188
189
 
189
190
  All other props are spread onto the `<a>` element.
190
191
 
192
+ #### `hash` — URL fragment / tab-style UIs
193
+
194
+ ```svelte
195
+ <Link routeName="settings" hash="profile">Profile</Link>
196
+ <Link routeName="settings" hash="account">Account</Link>
197
+ ```
198
+
199
+ Active class is hash-aware — only the matching tab lights up. Live demo: [`examples/web/react/link-hash/`](../../examples/web/react/link-hash/) — behavior is identical across adapters, only template syntax differs. See the [Hash Fragment Support](https://github.com/greydragon888/real-router/wiki/Hash) wiki page for the full surface.
200
+
191
201
  ### `<Lazy>`
192
202
 
193
203
  Lazy-load route content with a fallback component while loading. Useful for code-splitting and dynamic imports.
@@ -415,7 +425,7 @@ Opt-in preservation of scroll position across navigations:
415
425
  </RouterProvider>
416
426
  ```
417
427
 
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.
428
+ Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"native"`. Custom containers via `scrollContainer: () => HTMLElement | null`. Lifecycle tied to the provider — created on mount, destroyed on unmount. See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for details.
419
429
 
420
430
  ## View Transitions
421
431
 
@@ -42,16 +42,23 @@
42
42
  const srEnabled = $derived(scrollRestoration !== undefined);
43
43
  const srMode = $derived(scrollRestoration?.mode);
44
44
  const srAnchor = $derived(scrollRestoration?.anchorScrolling);
45
+ const srBehavior = $derived(scrollRestoration?.behavior);
46
+ const srStorageKey = $derived(scrollRestoration?.storageKey);
45
47
 
46
48
  $effect(() => {
47
49
  if (!srEnabled) return;
48
- // scrollContainer is a function ref that naturally changes each render.
49
- // Read it via `untrack` so this $effect does NOT depend on the parent
50
- // `scrollRestoration` signal. Without this, a new inline options object
51
- // would re-run the effect regardless of the primitive $derived memos.
50
+ // Read scrollRestoration object props via `untrack` for non-primitive
51
+ // refs that naturally change each render. Primitive $derived memos
52
+ // (mode/anchor/behavior/storageKey) drive re-runs.
53
+ void srMode;
54
+ void srAnchor;
55
+ void srBehavior;
56
+ void srStorageKey;
52
57
  const sr = createScrollRestoration(router, {
53
58
  mode: srMode,
54
59
  anchorScrolling: srAnchor,
60
+ behavior: srBehavior,
61
+ storageKey: srStorageKey,
55
62
  scrollContainer: untrack(() => scrollRestoration?.scrollContainer),
56
63
  });
57
64
  return () => sr.destroy();
@@ -6,6 +6,7 @@
6
6
  shouldNavigate,
7
7
  buildHref,
8
8
  buildActiveClassName,
9
+ navigateWithHash,
9
10
  } from "../dom-utils";
10
11
 
11
12
  import type { NavigationOptions, Params } from "@real-router/core";
@@ -19,6 +20,7 @@
19
20
  activeClassName = "active",
20
21
  activeStrict = false,
21
22
  ignoreQueryParams = true,
23
+ hash = undefined,
22
24
  target = undefined,
23
25
  children = undefined,
24
26
  onclick: userOnClick = undefined,
@@ -31,6 +33,13 @@
31
33
  activeClassName?: string;
32
34
  activeStrict?: boolean;
33
35
  ignoreQueryParams?: boolean;
36
+ /**
37
+ * URL fragment (decoded form, no leading "#") (#532).
38
+ * - omitted/`undefined` → preserve current fragment on same-route navigation
39
+ * - `""` → clear fragment
40
+ * - non-empty → set fragment
41
+ */
42
+ hash?: string;
34
43
  target?: string;
35
44
  children?: Snippet;
36
45
  onclick?: (evt: MouseEvent) => void;
@@ -38,14 +47,24 @@
38
47
  } = $props();
39
48
 
40
49
  const router = useRouter();
50
+ // Hash-aware active (#532): tab links sharing routeName but differing in
51
+ // hash should only light up the matching variant.
41
52
  const activeState = useIsActiveRoute(
42
53
  routeName,
43
54
  routeParams,
44
55
  activeStrict,
45
56
  ignoreQueryParams,
57
+ hash,
46
58
  );
47
59
 
48
- const href = $derived(buildHref(router, routeName, routeParams));
60
+ const href = $derived(
61
+ buildHref(
62
+ router,
63
+ routeName,
64
+ routeParams,
65
+ hash !== undefined ? { hash } : undefined,
66
+ ),
67
+ );
49
68
 
50
69
  const finalClassName = $derived(
51
70
  buildActiveClassName(activeState.current, activeClassName, className),
@@ -65,7 +84,9 @@
65
84
  }
66
85
 
67
86
  evt.preventDefault();
68
- router.navigate(routeName, routeParams, routeOptions).catch(NOOP);
87
+ navigateWithHash(router, routeName, routeParams, hash, routeOptions).catch(
88
+ NOOP,
89
+ );
69
90
  }
70
91
  </script>
71
92
 
@@ -8,6 +8,13 @@ type $$ComponentProps = {
8
8
  activeClassName?: string;
9
9
  activeStrict?: boolean;
10
10
  ignoreQueryParams?: boolean;
11
+ /**
12
+ * URL fragment (decoded form, no leading "#") (#532).
13
+ * - omitted/`undefined` → preserve current fragment on same-route navigation
14
+ * - `""` → clear fragment
15
+ * - non-empty → set fragment
16
+ */
17
+ hash?: string;
11
18
  target?: string;
12
19
  children?: Snippet;
13
20
  onclick?: (evt: MouseEvent) => void;
@@ -1,4 +1,4 @@
1
1
  import type { Params } from "@real-router/core";
2
- export declare function useIsActiveRoute(routeName: string, params: Params | undefined, strict: boolean, ignoreQueryParams: boolean): {
2
+ export declare function useIsActiveRoute(routeName: string, params: Params | undefined, strict: boolean, ignoreQueryParams: boolean, hash?: string): {
3
3
  readonly current: boolean;
4
4
  };
@@ -1,11 +1,13 @@
1
1
  import { createActiveRouteSource } from "@real-router/sources";
2
2
  import { createReactiveSource } from "../createReactiveSource.svelte";
3
3
  import { useRouter } from "./useRouter.svelte";
4
- export function useIsActiveRoute(routeName, params, strict, ignoreQueryParams) {
4
+ export function useIsActiveRoute(routeName, params, strict, ignoreQueryParams, hash) {
5
5
  const router = useRouter();
6
- const source = createActiveRouteSource(router, routeName, params, {
7
- strict,
8
- ignoreQueryParams,
9
- });
6
+ // The `hash` argument (#532) participates in the cache key when defined.
7
+ // exactOptionalPropertyTypes forbids `{ hash: undefined }` literally — we
8
+ // conditionally include the key only when a value is provided.
9
+ const source = createActiveRouteSource(router, routeName, params, hash === undefined
10
+ ? { strict, ignoreQueryParams }
11
+ : { strict, ignoreQueryParams, hash });
10
12
  return createReactiveSource(source);
11
13
  }
@@ -2,7 +2,7 @@ export { createDirectionTracker } from "./direction-tracker.js";
2
2
  export { createRouteAnnouncer } from "./route-announcer.js";
3
3
  export { createScrollRestoration } from "./scroll-restore.js";
4
4
  export { createViewTransitions } from "./view-transitions.js";
5
- export { shouldNavigate, buildHref, buildActiveClassName, shallowEqual, applyLinkA11y, } from "./link-utils.js";
5
+ export { shouldNavigate, buildHref, buildActiveClassName, navigateWithHash, shallowEqual, applyLinkA11y, } from "./link-utils.js";
6
6
  export type { RouteAnnouncerOptions } from "./route-announcer.js";
7
7
  export type { ScrollRestorationOptions, ScrollRestorationMode, } from "./scroll-restore.js";
8
8
  export type { DirectionTracker } from "./direction-tracker.js";
@@ -2,4 +2,4 @@ export { createDirectionTracker } from "./direction-tracker.js";
2
2
  export { createRouteAnnouncer } from "./route-announcer.js";
3
3
  export { createScrollRestoration } from "./scroll-restore.js";
4
4
  export { createViewTransitions } from "./view-transitions.js";
5
- export { shouldNavigate, buildHref, buildActiveClassName, shallowEqual, applyLinkA11y, } from "./link-utils.js";
5
+ export { shouldNavigate, buildHref, buildActiveClassName, navigateWithHash, shallowEqual, applyLinkA11y, } from "./link-utils.js";
@@ -1,6 +1,22 @@
1
- import type { Router, Params } from "@real-router/core";
1
+ import type { NavigationOptions, Params, Router, State } from "@real-router/core";
2
2
  export declare function shouldNavigate(evt: MouseEvent): boolean;
3
- export declare function buildHref(router: Router, routeName: string, routeParams: Params): string | undefined;
3
+ /**
4
+ * Builds an href for a `<Link>` element.
5
+ *
6
+ * - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
7
+ * hash-plugin) when present.
8
+ * - Falls back to `router.buildPath` for runtimes without a URL plugin
9
+ * (memory-plugin, console UIs, NativeScript). In that fallback the hash
10
+ * is appended manually so the rendered href is still correct.
11
+ * - The optional 4th argument is an options object so the contract stays
12
+ * extensible. The `hash` option is a decoded fragment without leading "#";
13
+ * `<Link hash="#section">` is accepted defensively (leading "#" stripped).
14
+ * Frozen API: previous 3-arg call sites continue to work unchanged.
15
+ */
16
+ export declare function buildHref(router: Router, routeName: string, routeParams: Params, options?: {
17
+ hash?: string;
18
+ }): string | undefined;
19
+ export declare function navigateWithHash(router: Router, routeName: string, routeParams: Params, hash: string | undefined, extraOptions?: NavigationOptions): Promise<State>;
4
20
  export declare function buildActiveClassName(isActive: boolean, activeClassName: string | undefined, baseClassName: string | undefined): string | undefined;
5
21
  export declare function shallowEqual(prev: object | undefined, next: object | undefined): boolean;
6
22
  export declare function applyLinkA11y(element: HTMLElement | null | undefined): void;
@@ -5,22 +5,69 @@ export function shouldNavigate(evt) {
5
5
  !evt.ctrlKey &&
6
6
  !evt.shiftKey);
7
7
  }
8
- export function buildHref(router, routeName, routeParams) {
8
+ /**
9
+ * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
10
+ * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
11
+ * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
12
+ * `shared/browser-env/url-context.ts` — duplicated here because the
13
+ * shared/dom-utils symlink graph does not reach shared/browser-env.
14
+ */
15
+ function encodeFragmentInline(decoded) {
16
+ return encodeURI(decoded).replaceAll("#", "%23");
17
+ }
18
+ /**
19
+ * Builds an href for a `<Link>` element.
20
+ *
21
+ * - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
22
+ * hash-plugin) when present.
23
+ * - Falls back to `router.buildPath` for runtimes without a URL plugin
24
+ * (memory-plugin, console UIs, NativeScript). In that fallback the hash
25
+ * is appended manually so the rendered href is still correct.
26
+ * - The optional 4th argument is an options object so the contract stays
27
+ * extensible. The `hash` option is a decoded fragment without leading "#";
28
+ * `<Link hash="#section">` is accepted defensively (leading "#" stripped).
29
+ * Frozen API: previous 3-arg call sites continue to work unchanged.
30
+ */
31
+ export function buildHref(router, routeName, routeParams, options) {
9
32
  try {
33
+ const rawHash = options?.hash;
34
+ let normHash;
35
+ if (rawHash !== undefined) {
36
+ normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
37
+ }
10
38
  const buildUrl = router.buildUrl;
11
39
  if (buildUrl) {
12
- const url = buildUrl(routeName, routeParams);
40
+ const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : { hash: normHash });
13
41
  if (url !== undefined) {
14
42
  return url;
15
43
  }
16
44
  }
17
- return router.buildPath(routeName, routeParams);
45
+ const path = router.buildPath(routeName, routeParams);
46
+ return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
18
47
  }
19
48
  catch {
20
49
  console.error(`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`);
21
50
  return undefined;
22
51
  }
23
52
  }
53
+ export function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
54
+ const opts = { ...extraOptions };
55
+ if (hash !== undefined) {
56
+ opts.hash = hash;
57
+ }
58
+ const current = router.getState();
59
+ if (current?.name === routeName &&
60
+ shallowEqual(current.params, routeParams)) {
61
+ const currentHash = current.context?.url?.hash ??
62
+ "";
63
+ const newHash = hash ?? currentHash;
64
+ if (currentHash !== newHash) {
65
+ opts.force = true;
66
+ opts.hashChange = true;
67
+ }
68
+ }
69
+ return router.navigate(routeName, routeParams, opts);
70
+ }
24
71
  function parseTokens(value) {
25
72
  return value ? (value.match(/\S+/g) ?? []) : [];
26
73
  }
@@ -1,9 +1,30 @@
1
1
  import type { Router } from "@real-router/core";
2
- export type ScrollRestorationMode = "restore" | "top" | "manual";
2
+ export type ScrollRestorationMode = "restore" | "top" | "native";
3
3
  export interface ScrollRestorationOptions {
4
4
  mode?: ScrollRestorationMode | undefined;
5
5
  anchorScrolling?: boolean | undefined;
6
6
  scrollContainer?: (() => HTMLElement | null) | undefined;
7
+ /**
8
+ * Scroll behavior passed to `scrollTo({ behavior })` and
9
+ * `scrollIntoView({ behavior })`.
10
+ *
11
+ * - `"auto"` (default) — browser-defined, usually instant.
12
+ * - `"instant"` — explicit instant jump (no animation).
13
+ * - `"smooth"` — animated transition. Note: smooth restore on back/traverse
14
+ * can feel disorienting if the user expects to land at the saved position
15
+ * immediately. Recommended for `mode: "top"` or anchor scroll only.
16
+ *
17
+ * See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior).
18
+ */
19
+ behavior?: ScrollBehavior | undefined;
20
+ /**
21
+ * sessionStorage key used to persist saved scroll positions. Default:
22
+ * `"real-router:scroll"`. Override only when multiple independent
23
+ * `RouterProvider` instances share the same document and you need to
24
+ * isolate their scroll stores (e.g. micro-frontends, embedded widgets,
25
+ * or testing). For a single app with one provider the default is fine.
26
+ */
27
+ storageKey?: string | undefined;
7
28
  }
8
29
  export declare function createScrollRestoration(router: Router, options?: ScrollRestorationOptions): {
9
30
  destroy: () => void;
@@ -1,4 +1,4 @@
1
- const STORAGE_KEY = "real-router:scroll";
1
+ const DEFAULT_STORAGE_KEY = "real-router:scroll";
2
2
  const NOOP_INSTANCE = Object.freeze({
3
3
  destroy: () => {
4
4
  /* no-op */
@@ -9,14 +9,38 @@ export function createScrollRestoration(router, options) {
9
9
  return NOOP_INSTANCE;
10
10
  }
11
11
  const mode = options?.mode ?? "restore";
12
- // mode "manual" = utility does nothing. Don't flip history.scrollRestoration,
13
- // don't subscribe, don't register pagehide — leave the browser's native
14
- // auto-restore intact for the app to override if it wants to.
15
- if (mode === "manual") {
12
+ // mode "native" = utility does nothing. Don't flip history.scrollRestoration,
13
+ // don't subscribe, don't register pagehide — `history.scrollRestoration`
14
+ // stays at the browser default ("auto") so the browser handles scroll
15
+ // restore natively. (Note: this is the OPPOSITE of `history.scrollRestoration
16
+ // === "manual"` — utility's "native" leaves the DOM property at "auto" so
17
+ // the browser is in charge.)
18
+ if (mode === "native") {
16
19
  return NOOP_INSTANCE;
17
20
  }
18
21
  const anchorEnabled = options?.anchorScrolling ?? true;
19
22
  const getContainer = options?.scrollContainer;
23
+ const behavior = options?.behavior ?? "auto";
24
+ const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
25
+ const loadStore = () => {
26
+ try {
27
+ const raw = sessionStorage.getItem(storageKey);
28
+ return raw ? JSON.parse(raw) : {};
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ };
34
+ const putPos = (key, pos) => {
35
+ try {
36
+ const store = loadStore();
37
+ store[key] = pos;
38
+ sessionStorage.setItem(storageKey, JSON.stringify(store));
39
+ }
40
+ catch {
41
+ // Ignore quota / security errors.
42
+ }
43
+ };
20
44
  const prevScrollRestoration = history.scrollRestoration;
21
45
  try {
22
46
  history.scrollRestoration = "manual";
@@ -34,18 +58,37 @@ export function createScrollRestoration(router, options) {
34
58
  const writePos = (top) => {
35
59
  const element = getContainer?.();
36
60
  if (element) {
37
- element.scrollTop = top;
61
+ element.scrollTo({ top, left: 0, behavior });
38
62
  }
39
63
  else {
40
- globalThis.scrollTo(0, top);
64
+ globalThis.scrollTo({ top, left: 0, behavior });
41
65
  }
42
66
  };
43
- const scrollToHashOrTop = () => {
67
+ const scrollToHashOrTop = (route) => {
68
+ // URL plugin path (#532): `state.context.url.hash` is the source of truth
69
+ // when one of the URL plugins (browser-plugin / navigation-plugin) is
70
+ // installed. The value is already DECODED — feeding it through
71
+ // `decodeURIComponent` again would throw on a bare `%`.
72
+ const ctxHash = route.context
73
+ ?.url?.hash;
74
+ if (ctxHash !== undefined) {
75
+ if (anchorEnabled && ctxHash.length > 0) {
76
+ // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
77
+ const element = document.getElementById(ctxHash);
78
+ if (element) {
79
+ element.scrollIntoView({ behavior });
80
+ return;
81
+ }
82
+ }
83
+ writePos(0);
84
+ return;
85
+ }
86
+ // Fallback path: no URL plugin, read the DOM. `location.hash` is
87
+ // percent-encoded; ids in the DOM are the raw string, so decode for the
88
+ // match. Fall back to the raw slice if the hash contains a malformed
89
+ // escape sequence (decodeURIComponent throws on those).
44
90
  const hash = globalThis.location.hash;
45
91
  if (anchorEnabled && hash.length > 1) {
46
- // location.hash is percent-encoded; ids in the DOM are the raw string.
47
- // Decode for the match. Fall back to the raw slice if the hash contains
48
- // a malformed escape sequence (decodeURIComponent throws on those).
49
92
  let id;
50
93
  try {
51
94
  id = decodeURIComponent(hash.slice(1));
@@ -56,7 +99,7 @@ export function createScrollRestoration(router, options) {
56
99
  // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
57
100
  const element = document.getElementById(id);
58
101
  if (element) {
59
- element.scrollIntoView();
102
+ element.scrollIntoView({ behavior });
60
103
  return;
61
104
  }
62
105
  }
@@ -79,7 +122,7 @@ export function createScrollRestoration(router, options) {
79
122
  return;
80
123
  }
81
124
  if (mode === "top" || !nav) {
82
- scrollToHashOrTop();
125
+ scrollToHashOrTop(route);
83
126
  return;
84
127
  }
85
128
  if (nav.navigationType === "replace") {
@@ -91,7 +134,7 @@ export function createScrollRestoration(router, options) {
91
134
  writePos(loadStore()[keyOf(route)] ?? 0);
92
135
  return;
93
136
  }
94
- scrollToHashOrTop();
137
+ scrollToHashOrTop(route);
95
138
  });
96
139
  });
97
140
  const onPageHide = () => {
@@ -121,25 +164,6 @@ export function createScrollRestoration(router, options) {
121
164
  function keyOf(state) {
122
165
  return `${state.name}:${canonicalJson(state.params)}`;
123
166
  }
124
- function loadStore() {
125
- try {
126
- const raw = sessionStorage.getItem(STORAGE_KEY);
127
- return raw ? JSON.parse(raw) : {};
128
- }
129
- catch {
130
- return {};
131
- }
132
- }
133
- function putPos(key, pos) {
134
- try {
135
- const store = loadStore();
136
- store[key] = pos;
137
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store));
138
- }
139
- catch {
140
- // Ignore quota / security errors.
141
- }
142
- }
143
167
  function canonicalJson(value) {
144
168
  return JSON.stringify(value, canonicalReplacer);
145
169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-router/svelte",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Svelte 5 integration for Real-Router",
6
6
  "svelte": "./dist/index.js",
@@ -46,7 +46,7 @@
46
46
  "dependencies": {
47
47
  "@real-router/core": "^0.51.0",
48
48
  "@real-router/route-utils": "^0.2.2",
49
- "@real-router/sources": "^0.7.3"
49
+ "@real-router/sources": "^0.8.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@sveltejs/package": "2.5.7",
@@ -58,7 +58,7 @@
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.16.1"
61
+ "@real-router/browser-plugin": "^0.17.0"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "svelte": ">=5.7.0"
@@ -42,16 +42,23 @@
42
42
  const srEnabled = $derived(scrollRestoration !== undefined);
43
43
  const srMode = $derived(scrollRestoration?.mode);
44
44
  const srAnchor = $derived(scrollRestoration?.anchorScrolling);
45
+ const srBehavior = $derived(scrollRestoration?.behavior);
46
+ const srStorageKey = $derived(scrollRestoration?.storageKey);
45
47
 
46
48
  $effect(() => {
47
49
  if (!srEnabled) return;
48
- // scrollContainer is a function ref that naturally changes each render.
49
- // Read it via `untrack` so this $effect does NOT depend on the parent
50
- // `scrollRestoration` signal. Without this, a new inline options object
51
- // would re-run the effect regardless of the primitive $derived memos.
50
+ // Read scrollRestoration object props via `untrack` for non-primitive
51
+ // refs that naturally change each render. Primitive $derived memos
52
+ // (mode/anchor/behavior/storageKey) drive re-runs.
53
+ void srMode;
54
+ void srAnchor;
55
+ void srBehavior;
56
+ void srStorageKey;
52
57
  const sr = createScrollRestoration(router, {
53
58
  mode: srMode,
54
59
  anchorScrolling: srAnchor,
60
+ behavior: srBehavior,
61
+ storageKey: srStorageKey,
55
62
  scrollContainer: untrack(() => scrollRestoration?.scrollContainer),
56
63
  });
57
64
  return () => sr.destroy();
@@ -6,6 +6,7 @@
6
6
  shouldNavigate,
7
7
  buildHref,
8
8
  buildActiveClassName,
9
+ navigateWithHash,
9
10
  } from "../dom-utils";
10
11
 
11
12
  import type { NavigationOptions, Params } from "@real-router/core";
@@ -19,6 +20,7 @@
19
20
  activeClassName = "active",
20
21
  activeStrict = false,
21
22
  ignoreQueryParams = true,
23
+ hash = undefined,
22
24
  target = undefined,
23
25
  children = undefined,
24
26
  onclick: userOnClick = undefined,
@@ -31,6 +33,13 @@
31
33
  activeClassName?: string;
32
34
  activeStrict?: boolean;
33
35
  ignoreQueryParams?: boolean;
36
+ /**
37
+ * URL fragment (decoded form, no leading "#") (#532).
38
+ * - omitted/`undefined` → preserve current fragment on same-route navigation
39
+ * - `""` → clear fragment
40
+ * - non-empty → set fragment
41
+ */
42
+ hash?: string;
34
43
  target?: string;
35
44
  children?: Snippet;
36
45
  onclick?: (evt: MouseEvent) => void;
@@ -38,14 +47,24 @@
38
47
  } = $props();
39
48
 
40
49
  const router = useRouter();
50
+ // Hash-aware active (#532): tab links sharing routeName but differing in
51
+ // hash should only light up the matching variant.
41
52
  const activeState = useIsActiveRoute(
42
53
  routeName,
43
54
  routeParams,
44
55
  activeStrict,
45
56
  ignoreQueryParams,
57
+ hash,
46
58
  );
47
59
 
48
- const href = $derived(buildHref(router, routeName, routeParams));
60
+ const href = $derived(
61
+ buildHref(
62
+ router,
63
+ routeName,
64
+ routeParams,
65
+ hash !== undefined ? { hash } : undefined,
66
+ ),
67
+ );
49
68
 
50
69
  const finalClassName = $derived(
51
70
  buildActiveClassName(activeState.current, activeClassName, className),
@@ -65,7 +84,9 @@
65
84
  }
66
85
 
67
86
  evt.preventDefault();
68
- router.navigate(routeName, routeParams, routeOptions).catch(NOOP);
87
+ navigateWithHash(router, routeName, routeParams, hash, routeOptions).catch(
88
+ NOOP,
89
+ );
69
90
  }
70
91
  </script>
71
92
 
@@ -10,13 +10,21 @@ export function useIsActiveRoute(
10
10
  params: Params | undefined,
11
11
  strict: boolean,
12
12
  ignoreQueryParams: boolean,
13
+ hash?: string,
13
14
  ): { readonly current: boolean } {
14
15
  const router = useRouter();
15
16
 
16
- const source = createActiveRouteSource(router, routeName, params, {
17
- strict,
18
- ignoreQueryParams,
19
- });
17
+ // The `hash` argument (#532) participates in the cache key when defined.
18
+ // exactOptionalPropertyTypes forbids `{ hash: undefined }` literally — we
19
+ // conditionally include the key only when a value is provided.
20
+ const source = createActiveRouteSource(
21
+ router,
22
+ routeName,
23
+ params,
24
+ hash === undefined
25
+ ? { strict, ignoreQueryParams }
26
+ : { strict, ignoreQueryParams, hash },
27
+ );
20
28
 
21
29
  return createReactiveSource(source);
22
30
  }