@real-router/solid 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -191,6 +191,15 @@ Navigation link with automatic active state detection. Uses `classList` for acti
191
191
  </Link>
192
192
  ```
193
193
 
194
+ #### `hash` prop — URL fragment / tab-style UIs
195
+
196
+ ```tsx
197
+ <Link routeName="settings" hash="profile">Profile</Link>
198
+ <Link routeName="settings" hash="account">Account</Link>
199
+ ```
200
+
201
+ Tri-state: `undefined` preserves the current hash, `""` clears it, a value sets it. Active class is hash-aware — only the matching tab lights up. Setting `hash` forces the slow path (the fast-path `routeSelector` is hash-agnostic). 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.
202
+
194
203
  ### `<RouteView>`
195
204
 
196
205
  Declarative route matching. Renders the first matching `<RouteView.Match>` child.
@@ -363,7 +372,7 @@ Opt-in preservation of scroll position across navigations:
363
372
  </RouterProvider>
364
373
  ```
365
374
 
366
- Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"manual"`. Custom containers via `scrollContainer: () => HTMLElement | null`. Options are read once on mount — changing the prop at runtime does not reconfigure the utility (Solid `onMount` is non-reactive). See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for details.
375
+ Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"native"`. Custom containers via `scrollContainer: () => HTMLElement | null`. Options are read once on mount — changing the prop at runtime does not reconfigure the utility (Solid `onMount` is non-reactive). See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for details.
367
376
 
368
377
  ## View Transitions
369
378
 
@@ -61,6 +61,13 @@ interface LinkProps<P extends Params = Params> extends Omit<JSX.HTMLAttributes<H
61
61
  activeClassName?: string;
62
62
  activeStrict?: boolean;
63
63
  ignoreQueryParams?: boolean;
64
+ /**
65
+ * URL fragment (decoded form, no leading "#") (#532).
66
+ * - omitted/`undefined` → preserve current fragment on same-route navigation
67
+ * - `""` → clear fragment
68
+ * - non-empty → set fragment
69
+ */
70
+ hash?: string;
64
71
  target?: string;
65
72
  onClick?: (evt: MouseEvent) => void;
66
73
  }
@@ -266,11 +273,32 @@ interface UseRouteEnterOptions {
266
273
  */
267
274
  declare function useRouteEnter(handler: RouteEnterHandler, options?: UseRouteEnterOptions): void;
268
275
 
269
- type ScrollRestorationMode = "restore" | "top" | "manual";
276
+ type ScrollRestorationMode = "restore" | "top" | "native";
270
277
  interface ScrollRestorationOptions {
271
278
  mode?: ScrollRestorationMode | undefined;
272
279
  anchorScrolling?: boolean | undefined;
273
280
  scrollContainer?: (() => HTMLElement | null) | undefined;
281
+ /**
282
+ * Scroll behavior passed to `scrollTo({ behavior })` and
283
+ * `scrollIntoView({ behavior })`.
284
+ *
285
+ * - `"auto"` (default) — browser-defined, usually instant.
286
+ * - `"instant"` — explicit instant jump (no animation).
287
+ * - `"smooth"` — animated transition. Note: smooth restore on back/traverse
288
+ * can feel disorienting if the user expects to land at the saved position
289
+ * immediately. Recommended for `mode: "top"` or anchor scroll only.
290
+ *
291
+ * See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior).
292
+ */
293
+ behavior?: ScrollBehavior | undefined;
294
+ /**
295
+ * sessionStorage key used to persist saved scroll positions. Default:
296
+ * `"real-router:scroll"`. Override only when multiple independent
297
+ * `RouterProvider` instances share the same document and you need to
298
+ * isolate their scroll stores (e.g. micro-frontends, embedded widgets,
299
+ * or testing). For a single app with one provider the default is fine.
300
+ */
301
+ storageKey?: string | undefined;
274
302
  }
275
303
 
276
304
  interface RouteProviderProps {
package/dist/cjs/index.js CHANGED
@@ -336,7 +336,7 @@ function manageFocus(h1) {
336
336
  });
337
337
  }
338
338
 
339
- const STORAGE_KEY = "real-router:scroll";
339
+ const DEFAULT_STORAGE_KEY = "real-router:scroll";
340
340
  const NOOP_INSTANCE$1 = Object.freeze({
341
341
  destroy: () => {
342
342
  /* no-op */
@@ -348,14 +348,36 @@ function createScrollRestoration(router, options) {
348
348
  }
349
349
  const mode = options?.mode ?? "restore";
350
350
 
351
- // mode "manual" = utility does nothing. Don't flip history.scrollRestoration,
352
- // don't subscribe, don't register pagehide — leave the browser's native
353
- // auto-restore intact for the app to override if it wants to.
354
- if (mode === "manual") {
351
+ // mode "native" = utility does nothing. Don't flip history.scrollRestoration,
352
+ // don't subscribe, don't register pagehide — `history.scrollRestoration`
353
+ // stays at the browser default ("auto") so the browser handles scroll
354
+ // restore natively. (Note: this is the OPPOSITE of `history.scrollRestoration
355
+ // === "manual"` — utility's "native" leaves the DOM property at "auto" so
356
+ // the browser is in charge.)
357
+ if (mode === "native") {
355
358
  return NOOP_INSTANCE$1;
356
359
  }
357
360
  const anchorEnabled = options?.anchorScrolling ?? true;
358
361
  const getContainer = options?.scrollContainer;
362
+ const behavior = options?.behavior ?? "auto";
363
+ const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
364
+ const loadStore = () => {
365
+ try {
366
+ const raw = sessionStorage.getItem(storageKey);
367
+ return raw ? JSON.parse(raw) : {};
368
+ } catch {
369
+ return {};
370
+ }
371
+ };
372
+ const putPos = (key, pos) => {
373
+ try {
374
+ const store = loadStore();
375
+ store[key] = pos;
376
+ sessionStorage.setItem(storageKey, JSON.stringify(store));
377
+ } catch {
378
+ // Ignore quota / security errors.
379
+ }
380
+ };
359
381
  const prevScrollRestoration = history.scrollRestoration;
360
382
  try {
361
383
  history.scrollRestoration = "manual";
@@ -373,17 +395,46 @@ function createScrollRestoration(router, options) {
373
395
  const writePos = top => {
374
396
  const element = getContainer?.();
375
397
  if (element) {
376
- element.scrollTop = top;
398
+ element.scrollTo({
399
+ top,
400
+ left: 0,
401
+ behavior
402
+ });
377
403
  } else {
378
- globalThis.scrollTo(0, top);
404
+ globalThis.scrollTo({
405
+ top,
406
+ left: 0,
407
+ behavior
408
+ });
379
409
  }
380
410
  };
381
- const scrollToHashOrTop = () => {
411
+ const scrollToHashOrTop = route => {
412
+ // URL plugin path (#532): `state.context.url.hash` is the source of truth
413
+ // when one of the URL plugins (browser-plugin / navigation-plugin) is
414
+ // installed. The value is already DECODED — feeding it through
415
+ // `decodeURIComponent` again would throw on a bare `%`.
416
+ const ctxHash = route.context?.url?.hash;
417
+ if (ctxHash !== undefined) {
418
+ if (anchorEnabled && ctxHash.length > 0) {
419
+ // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
420
+ const element = document.getElementById(ctxHash);
421
+ if (element) {
422
+ element.scrollIntoView({
423
+ behavior
424
+ });
425
+ return;
426
+ }
427
+ }
428
+ writePos(0);
429
+ return;
430
+ }
431
+
432
+ // Fallback path: no URL plugin, read the DOM. `location.hash` is
433
+ // percent-encoded; ids in the DOM are the raw string, so decode for the
434
+ // match. Fall back to the raw slice if the hash contains a malformed
435
+ // escape sequence (decodeURIComponent throws on those).
382
436
  const hash = globalThis.location.hash;
383
437
  if (anchorEnabled && hash.length > 1) {
384
- // location.hash is percent-encoded; ids in the DOM are the raw string.
385
- // Decode for the match. Fall back to the raw slice if the hash contains
386
- // a malformed escape sequence (decodeURIComponent throws on those).
387
438
  let id;
388
439
  try {
389
440
  id = decodeURIComponent(hash.slice(1));
@@ -394,7 +445,9 @@ function createScrollRestoration(router, options) {
394
445
  // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
395
446
  const element = document.getElementById(id);
396
447
  if (element) {
397
- element.scrollIntoView();
448
+ element.scrollIntoView({
449
+ behavior
450
+ });
398
451
  return;
399
452
  }
400
453
  }
@@ -421,7 +474,7 @@ function createScrollRestoration(router, options) {
421
474
  return;
422
475
  }
423
476
  if (mode === "top" || !nav) {
424
- scrollToHashOrTop();
477
+ scrollToHashOrTop(route);
425
478
  return;
426
479
  }
427
480
  if (nav.navigationType === "replace") {
@@ -431,7 +484,7 @@ function createScrollRestoration(router, options) {
431
484
  writePos(loadStore()[keyOf(route)] ?? 0);
432
485
  return;
433
486
  }
434
- scrollToHashOrTop();
487
+ scrollToHashOrTop(route);
435
488
  });
436
489
  });
437
490
  const onPageHide = () => {
@@ -460,23 +513,6 @@ function createScrollRestoration(router, options) {
460
513
  function keyOf(state) {
461
514
  return `${state.name}:${canonicalJson(state.params)}`;
462
515
  }
463
- function loadStore() {
464
- try {
465
- const raw = sessionStorage.getItem(STORAGE_KEY);
466
- return raw ? JSON.parse(raw) : {};
467
- } catch {
468
- return {};
469
- }
470
- }
471
- function putPos(key, pos) {
472
- try {
473
- const store = loadStore();
474
- store[key] = pos;
475
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store));
476
- } catch {
477
- // Ignore quota / security errors.
478
- }
479
- }
480
516
  function canonicalJson(value) {
481
517
  return JSON.stringify(value, canonicalReplacer);
482
518
  }
@@ -618,21 +654,94 @@ function createViewTransitions(router) {
618
654
  function shouldNavigate(evt) {
619
655
  return evt.button === 0 && !evt.metaKey && !evt.altKey && !evt.ctrlKey && !evt.shiftKey;
620
656
  }
621
- function buildHref(router, routeName, routeParams) {
657
+
658
+ /**
659
+ * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
660
+ * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
661
+ * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
662
+ * `shared/browser-env/url-context.ts` — duplicated here because the
663
+ * shared/dom-utils symlink graph does not reach shared/browser-env.
664
+ */
665
+ function encodeFragmentInline(decoded) {
666
+ return encodeURI(decoded).replaceAll("#", "%23");
667
+ }
668
+ /**
669
+ * Builds an href for a `<Link>` element.
670
+ *
671
+ * - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
672
+ * hash-plugin) when present.
673
+ * - Falls back to `router.buildPath` for runtimes without a URL plugin
674
+ * (memory-plugin, console UIs, NativeScript). In that fallback the hash
675
+ * is appended manually so the rendered href is still correct.
676
+ * - The optional 4th argument is an options object so the contract stays
677
+ * extensible. The `hash` option is a decoded fragment without leading "#";
678
+ * `<Link hash="#section">` is accepted defensively (leading "#" stripped).
679
+ * Frozen API: previous 3-arg call sites continue to work unchanged.
680
+ */
681
+ function buildHref(router, routeName, routeParams, options) {
622
682
  try {
683
+ const rawHash = options?.hash;
684
+ let normHash;
685
+ if (rawHash !== undefined) {
686
+ normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
687
+ }
623
688
  const buildUrl = router.buildUrl;
624
689
  if (buildUrl) {
625
- const url = buildUrl(routeName, routeParams);
690
+ const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : {
691
+ hash: normHash
692
+ });
626
693
  if (url !== undefined) {
627
694
  return url;
628
695
  }
629
696
  }
630
- return router.buildPath(routeName, routeParams);
697
+ const path = router.buildPath(routeName, routeParams);
698
+ return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
631
699
  } catch {
632
700
  console.error(`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`);
633
701
  return undefined;
634
702
  }
635
703
  }
704
+
705
+ /**
706
+ * `<Link>` click-handler navigation helper (#532).
707
+ *
708
+ * Wraps `router.navigate(name, params, opts)` with same-route different-hash
709
+ * detection: when the consumer clicks a hash-bearing Link that targets the
710
+ * current route with the same params but a different fragment, core's
711
+ * SAME_STATES check would otherwise reject the navigation. The helper adds
712
+ * `force: true` and `hashChange: true` automatically — subscribers can then
713
+ * disambiguate via `state.context.url.hashChanged`.
714
+ *
715
+ * For pure programmatic same-route hash-only navigation, callers are
716
+ * documented to pass `{ force: true }` themselves; the auto-bypass here is
717
+ * a UX convenience for `<Link hash>` that all 6 framework adapters share.
718
+ */
719
+ /**
720
+ * Local extended-options type. Adapters that depend only on `@real-router/core`
721
+ * (without a URL plugin) do not see the `NavigationOptions` augmentation that
722
+ * declares `hash` / `hashChange`. Casting to this widened type inside the
723
+ * helper keeps shared/dom-utils self-contained — adapters do not need to
724
+ * augment NavigationOptions themselves to consume `<Link hash>`.
725
+ */
726
+
727
+ function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
728
+ const opts = {
729
+ ...extraOptions
730
+ };
731
+ if (hash !== undefined) {
732
+ opts.hash = hash;
733
+ }
734
+ const current = router.getState();
735
+ if (current?.name === routeName && shallowEqual(current.params, routeParams)) {
736
+ const currentHash = current.context?.url?.hash ?? "";
737
+ const newHash = hash ?? currentHash;
738
+ if (currentHash !== newHash) {
739
+ opts.force = true;
740
+ opts.hashChange = true;
741
+ }
742
+ }
743
+ return router.navigate(routeName, routeParams, opts);
744
+ }
636
745
  function parseTokens(value) {
637
746
  return value ? value.match(/\S+/g) ?? [] : [];
638
747
  }
@@ -657,6 +766,26 @@ function buildActiveClassName(isActive, activeClassName, baseClassName) {
657
766
  }
658
767
  return baseClassName ?? undefined;
659
768
  }
769
+ function shallowEqual(prev, next) {
770
+ if (Object.is(prev, next)) {
771
+ return true;
772
+ }
773
+ if (!prev || !next) {
774
+ return false;
775
+ }
776
+ const prevKeys = Object.keys(prev);
777
+ if (prevKeys.length !== Object.keys(next).length) {
778
+ return false;
779
+ }
780
+ const prevRecord = prev;
781
+ const nextRecord = next;
782
+ for (const key of prevKeys) {
783
+ if (!Object.is(prevRecord[key], nextRecord[key])) {
784
+ return false;
785
+ }
786
+ }
787
+ return true;
788
+ }
660
789
  function applyLinkA11y(element) {
661
790
  if (!element) {
662
791
  return;
@@ -681,18 +810,35 @@ function Link(props) {
681
810
  activeStrict: false,
682
811
  ignoreQueryParams: true
683
812
  }, props);
684
- const [local, rest] = solidJs.splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "onClick", "target", "class", "children"]);
813
+ const [local, rest] = solidJs.splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "hash", "onClick", "target", "class", "children"]);
685
814
  const ctx = solidJs.useContext(RouterContext);
686
815
  if (!ctx) {
687
816
  throw new Error("Link must be used within a RouterProvider");
688
817
  }
689
818
  const router = ctx.router;
690
- const useFastPath = !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
691
- const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(sources.createActiveRouteSource(router, local.routeName, local.routeParams, {
692
- strict: local.activeStrict,
693
- ignoreQueryParams: local.ignoreQueryParams
819
+
820
+ // Hash-aware active state (#532). `routeSelector` (the O(1) shared selector)
821
+ // doesn't know about hash — when `hash` prop is set, fall back to the slow
822
+ // path so the source's hash comparison kicks in. Tab-style UI is opt-in via
823
+ // the prop, so the fast path stays open for the typical Link case.
824
+ const useFastPath = local.hash === undefined && !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
825
+ const buildActiveOptions = () => {
826
+ const base = {
827
+ strict: local.activeStrict,
828
+ ignoreQueryParams: local.ignoreQueryParams
829
+ };
830
+ if (local.hash === undefined) {
831
+ return base;
832
+ }
833
+ return {
834
+ ...base,
835
+ hash: local.hash
836
+ };
837
+ };
838
+ const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(sources.createActiveRouteSource(router, local.routeName, local.routeParams, buildActiveOptions()));
839
+ const href = solidJs.createMemo(() => buildHref(router, local.routeName, local.routeParams, local.hash === undefined ? undefined : {
840
+ hash: local.hash
694
841
  }));
695
- const href = solidJs.createMemo(() => buildHref(router, local.routeName, local.routeParams));
696
842
  const handleClick = evt => {
697
843
  if (local.onClick) {
698
844
  local.onClick(evt);
@@ -704,7 +850,7 @@ function Link(props) {
704
850
  return;
705
851
  }
706
852
  evt.preventDefault();
707
- router.navigate(local.routeName, local.routeParams, local.routeOptions).catch(() => {});
853
+ navigateWithHash(router, local.routeName, local.routeParams, local.hash, local.routeOptions).catch(() => {});
708
854
  };
709
855
  const finalClassName = solidJs.createMemo(() => buildActiveClassName(isActive(), local.activeClassName, local.class));
710
856
  return (() => {
@@ -61,6 +61,13 @@ interface LinkProps<P extends Params = Params> extends Omit<JSX.HTMLAttributes<H
61
61
  activeClassName?: string;
62
62
  activeStrict?: boolean;
63
63
  ignoreQueryParams?: boolean;
64
+ /**
65
+ * URL fragment (decoded form, no leading "#") (#532).
66
+ * - omitted/`undefined` → preserve current fragment on same-route navigation
67
+ * - `""` → clear fragment
68
+ * - non-empty → set fragment
69
+ */
70
+ hash?: string;
64
71
  target?: string;
65
72
  onClick?: (evt: MouseEvent) => void;
66
73
  }
@@ -266,11 +273,32 @@ interface UseRouteEnterOptions {
266
273
  */
267
274
  declare function useRouteEnter(handler: RouteEnterHandler, options?: UseRouteEnterOptions): void;
268
275
 
269
- type ScrollRestorationMode = "restore" | "top" | "manual";
276
+ type ScrollRestorationMode = "restore" | "top" | "native";
270
277
  interface ScrollRestorationOptions {
271
278
  mode?: ScrollRestorationMode | undefined;
272
279
  anchorScrolling?: boolean | undefined;
273
280
  scrollContainer?: (() => HTMLElement | null) | undefined;
281
+ /**
282
+ * Scroll behavior passed to `scrollTo({ behavior })` and
283
+ * `scrollIntoView({ behavior })`.
284
+ *
285
+ * - `"auto"` (default) — browser-defined, usually instant.
286
+ * - `"instant"` — explicit instant jump (no animation).
287
+ * - `"smooth"` — animated transition. Note: smooth restore on back/traverse
288
+ * can feel disorienting if the user expects to land at the saved position
289
+ * immediately. Recommended for `mode: "top"` or anchor scroll only.
290
+ *
291
+ * See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior).
292
+ */
293
+ behavior?: ScrollBehavior | undefined;
294
+ /**
295
+ * sessionStorage key used to persist saved scroll positions. Default:
296
+ * `"real-router:scroll"`. Override only when multiple independent
297
+ * `RouterProvider` instances share the same document and you need to
298
+ * isolate their scroll stores (e.g. micro-frontends, embedded widgets,
299
+ * or testing). For a single app with one provider the default is fine.
300
+ */
301
+ storageKey?: string | undefined;
274
302
  }
275
303
 
276
304
  interface RouteProviderProps {
@@ -334,7 +334,7 @@ function manageFocus(h1) {
334
334
  });
335
335
  }
336
336
 
337
- const STORAGE_KEY = "real-router:scroll";
337
+ const DEFAULT_STORAGE_KEY = "real-router:scroll";
338
338
  const NOOP_INSTANCE$1 = Object.freeze({
339
339
  destroy: () => {
340
340
  /* no-op */
@@ -346,14 +346,36 @@ function createScrollRestoration(router, options) {
346
346
  }
347
347
  const mode = options?.mode ?? "restore";
348
348
 
349
- // mode "manual" = utility does nothing. Don't flip history.scrollRestoration,
350
- // don't subscribe, don't register pagehide — leave the browser's native
351
- // auto-restore intact for the app to override if it wants to.
352
- if (mode === "manual") {
349
+ // mode "native" = utility does nothing. Don't flip history.scrollRestoration,
350
+ // don't subscribe, don't register pagehide — `history.scrollRestoration`
351
+ // stays at the browser default ("auto") so the browser handles scroll
352
+ // restore natively. (Note: this is the OPPOSITE of `history.scrollRestoration
353
+ // === "manual"` — utility's "native" leaves the DOM property at "auto" so
354
+ // the browser is in charge.)
355
+ if (mode === "native") {
353
356
  return NOOP_INSTANCE$1;
354
357
  }
355
358
  const anchorEnabled = options?.anchorScrolling ?? true;
356
359
  const getContainer = options?.scrollContainer;
360
+ const behavior = options?.behavior ?? "auto";
361
+ const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
362
+ const loadStore = () => {
363
+ try {
364
+ const raw = sessionStorage.getItem(storageKey);
365
+ return raw ? JSON.parse(raw) : {};
366
+ } catch {
367
+ return {};
368
+ }
369
+ };
370
+ const putPos = (key, pos) => {
371
+ try {
372
+ const store = loadStore();
373
+ store[key] = pos;
374
+ sessionStorage.setItem(storageKey, JSON.stringify(store));
375
+ } catch {
376
+ // Ignore quota / security errors.
377
+ }
378
+ };
357
379
  const prevScrollRestoration = history.scrollRestoration;
358
380
  try {
359
381
  history.scrollRestoration = "manual";
@@ -371,17 +393,46 @@ function createScrollRestoration(router, options) {
371
393
  const writePos = top => {
372
394
  const element = getContainer?.();
373
395
  if (element) {
374
- element.scrollTop = top;
396
+ element.scrollTo({
397
+ top,
398
+ left: 0,
399
+ behavior
400
+ });
375
401
  } else {
376
- globalThis.scrollTo(0, top);
402
+ globalThis.scrollTo({
403
+ top,
404
+ left: 0,
405
+ behavior
406
+ });
377
407
  }
378
408
  };
379
- const scrollToHashOrTop = () => {
409
+ const scrollToHashOrTop = route => {
410
+ // URL plugin path (#532): `state.context.url.hash` is the source of truth
411
+ // when one of the URL plugins (browser-plugin / navigation-plugin) is
412
+ // installed. The value is already DECODED — feeding it through
413
+ // `decodeURIComponent` again would throw on a bare `%`.
414
+ const ctxHash = route.context?.url?.hash;
415
+ if (ctxHash !== undefined) {
416
+ if (anchorEnabled && ctxHash.length > 0) {
417
+ // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
418
+ const element = document.getElementById(ctxHash);
419
+ if (element) {
420
+ element.scrollIntoView({
421
+ behavior
422
+ });
423
+ return;
424
+ }
425
+ }
426
+ writePos(0);
427
+ return;
428
+ }
429
+
430
+ // Fallback path: no URL plugin, read the DOM. `location.hash` is
431
+ // percent-encoded; ids in the DOM are the raw string, so decode for the
432
+ // match. Fall back to the raw slice if the hash contains a malformed
433
+ // escape sequence (decodeURIComponent throws on those).
380
434
  const hash = globalThis.location.hash;
381
435
  if (anchorEnabled && hash.length > 1) {
382
- // location.hash is percent-encoded; ids in the DOM are the raw string.
383
- // Decode for the match. Fall back to the raw slice if the hash contains
384
- // a malformed escape sequence (decodeURIComponent throws on those).
385
436
  let id;
386
437
  try {
387
438
  id = decodeURIComponent(hash.slice(1));
@@ -392,7 +443,9 @@ function createScrollRestoration(router, options) {
392
443
  // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
393
444
  const element = document.getElementById(id);
394
445
  if (element) {
395
- element.scrollIntoView();
446
+ element.scrollIntoView({
447
+ behavior
448
+ });
396
449
  return;
397
450
  }
398
451
  }
@@ -419,7 +472,7 @@ function createScrollRestoration(router, options) {
419
472
  return;
420
473
  }
421
474
  if (mode === "top" || !nav) {
422
- scrollToHashOrTop();
475
+ scrollToHashOrTop(route);
423
476
  return;
424
477
  }
425
478
  if (nav.navigationType === "replace") {
@@ -429,7 +482,7 @@ function createScrollRestoration(router, options) {
429
482
  writePos(loadStore()[keyOf(route)] ?? 0);
430
483
  return;
431
484
  }
432
- scrollToHashOrTop();
485
+ scrollToHashOrTop(route);
433
486
  });
434
487
  });
435
488
  const onPageHide = () => {
@@ -458,23 +511,6 @@ function createScrollRestoration(router, options) {
458
511
  function keyOf(state) {
459
512
  return `${state.name}:${canonicalJson(state.params)}`;
460
513
  }
461
- function loadStore() {
462
- try {
463
- const raw = sessionStorage.getItem(STORAGE_KEY);
464
- return raw ? JSON.parse(raw) : {};
465
- } catch {
466
- return {};
467
- }
468
- }
469
- function putPos(key, pos) {
470
- try {
471
- const store = loadStore();
472
- store[key] = pos;
473
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store));
474
- } catch {
475
- // Ignore quota / security errors.
476
- }
477
- }
478
514
  function canonicalJson(value) {
479
515
  return JSON.stringify(value, canonicalReplacer);
480
516
  }
@@ -616,21 +652,94 @@ function createViewTransitions(router) {
616
652
  function shouldNavigate(evt) {
617
653
  return evt.button === 0 && !evt.metaKey && !evt.altKey && !evt.ctrlKey && !evt.shiftKey;
618
654
  }
619
- function buildHref(router, routeName, routeParams) {
655
+
656
+ /**
657
+ * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
658
+ * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
659
+ * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
660
+ * `shared/browser-env/url-context.ts` — duplicated here because the
661
+ * shared/dom-utils symlink graph does not reach shared/browser-env.
662
+ */
663
+ function encodeFragmentInline(decoded) {
664
+ return encodeURI(decoded).replaceAll("#", "%23");
665
+ }
666
+ /**
667
+ * Builds an href for a `<Link>` element.
668
+ *
669
+ * - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
670
+ * hash-plugin) when present.
671
+ * - Falls back to `router.buildPath` for runtimes without a URL plugin
672
+ * (memory-plugin, console UIs, NativeScript). In that fallback the hash
673
+ * is appended manually so the rendered href is still correct.
674
+ * - The optional 4th argument is an options object so the contract stays
675
+ * extensible. The `hash` option is a decoded fragment without leading "#";
676
+ * `<Link hash="#section">` is accepted defensively (leading "#" stripped).
677
+ * Frozen API: previous 3-arg call sites continue to work unchanged.
678
+ */
679
+ function buildHref(router, routeName, routeParams, options) {
620
680
  try {
681
+ const rawHash = options?.hash;
682
+ let normHash;
683
+ if (rawHash !== undefined) {
684
+ normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
685
+ }
621
686
  const buildUrl = router.buildUrl;
622
687
  if (buildUrl) {
623
- const url = buildUrl(routeName, routeParams);
688
+ const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : {
689
+ hash: normHash
690
+ });
624
691
  if (url !== undefined) {
625
692
  return url;
626
693
  }
627
694
  }
628
- return router.buildPath(routeName, routeParams);
695
+ const path = router.buildPath(routeName, routeParams);
696
+ return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
629
697
  } catch {
630
698
  console.error(`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`);
631
699
  return undefined;
632
700
  }
633
701
  }
702
+
703
+ /**
704
+ * `<Link>` click-handler navigation helper (#532).
705
+ *
706
+ * Wraps `router.navigate(name, params, opts)` with same-route different-hash
707
+ * detection: when the consumer clicks a hash-bearing Link that targets the
708
+ * current route with the same params but a different fragment, core's
709
+ * SAME_STATES check would otherwise reject the navigation. The helper adds
710
+ * `force: true` and `hashChange: true` automatically — subscribers can then
711
+ * disambiguate via `state.context.url.hashChanged`.
712
+ *
713
+ * For pure programmatic same-route hash-only navigation, callers are
714
+ * documented to pass `{ force: true }` themselves; the auto-bypass here is
715
+ * a UX convenience for `<Link hash>` that all 6 framework adapters share.
716
+ */
717
+ /**
718
+ * Local extended-options type. Adapters that depend only on `@real-router/core`
719
+ * (without a URL plugin) do not see the `NavigationOptions` augmentation that
720
+ * declares `hash` / `hashChange`. Casting to this widened type inside the
721
+ * helper keeps shared/dom-utils self-contained — adapters do not need to
722
+ * augment NavigationOptions themselves to consume `<Link hash>`.
723
+ */
724
+
725
+ function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
726
+ const opts = {
727
+ ...extraOptions
728
+ };
729
+ if (hash !== undefined) {
730
+ opts.hash = hash;
731
+ }
732
+ const current = router.getState();
733
+ if (current?.name === routeName && shallowEqual(current.params, routeParams)) {
734
+ const currentHash = current.context?.url?.hash ?? "";
735
+ const newHash = hash ?? currentHash;
736
+ if (currentHash !== newHash) {
737
+ opts.force = true;
738
+ opts.hashChange = true;
739
+ }
740
+ }
741
+ return router.navigate(routeName, routeParams, opts);
742
+ }
634
743
  function parseTokens(value) {
635
744
  return value ? value.match(/\S+/g) ?? [] : [];
636
745
  }
@@ -655,6 +764,26 @@ function buildActiveClassName(isActive, activeClassName, baseClassName) {
655
764
  }
656
765
  return baseClassName ?? undefined;
657
766
  }
767
+ function shallowEqual(prev, next) {
768
+ if (Object.is(prev, next)) {
769
+ return true;
770
+ }
771
+ if (!prev || !next) {
772
+ return false;
773
+ }
774
+ const prevKeys = Object.keys(prev);
775
+ if (prevKeys.length !== Object.keys(next).length) {
776
+ return false;
777
+ }
778
+ const prevRecord = prev;
779
+ const nextRecord = next;
780
+ for (const key of prevKeys) {
781
+ if (!Object.is(prevRecord[key], nextRecord[key])) {
782
+ return false;
783
+ }
784
+ }
785
+ return true;
786
+ }
658
787
  function applyLinkA11y(element) {
659
788
  if (!element) {
660
789
  return;
@@ -679,18 +808,35 @@ function Link(props) {
679
808
  activeStrict: false,
680
809
  ignoreQueryParams: true
681
810
  }, props);
682
- const [local, rest] = splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "onClick", "target", "class", "children"]);
811
+ const [local, rest] = splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "hash", "onClick", "target", "class", "children"]);
683
812
  const ctx = useContext(RouterContext);
684
813
  if (!ctx) {
685
814
  throw new Error("Link must be used within a RouterProvider");
686
815
  }
687
816
  const router = ctx.router;
688
- const useFastPath = !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
689
- const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(createActiveRouteSource(router, local.routeName, local.routeParams, {
690
- strict: local.activeStrict,
691
- ignoreQueryParams: local.ignoreQueryParams
817
+
818
+ // Hash-aware active state (#532). `routeSelector` (the O(1) shared selector)
819
+ // doesn't know about hash — when `hash` prop is set, fall back to the slow
820
+ // path so the source's hash comparison kicks in. Tab-style UI is opt-in via
821
+ // the prop, so the fast path stays open for the typical Link case.
822
+ const useFastPath = local.hash === undefined && !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
823
+ const buildActiveOptions = () => {
824
+ const base = {
825
+ strict: local.activeStrict,
826
+ ignoreQueryParams: local.ignoreQueryParams
827
+ };
828
+ if (local.hash === undefined) {
829
+ return base;
830
+ }
831
+ return {
832
+ ...base,
833
+ hash: local.hash
834
+ };
835
+ };
836
+ const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(createActiveRouteSource(router, local.routeName, local.routeParams, buildActiveOptions()));
837
+ const href = createMemo(() => buildHref(router, local.routeName, local.routeParams, local.hash === undefined ? undefined : {
838
+ hash: local.hash
692
839
  }));
693
- const href = createMemo(() => buildHref(router, local.routeName, local.routeParams));
694
840
  const handleClick = evt => {
695
841
  if (local.onClick) {
696
842
  local.onClick(evt);
@@ -702,7 +848,7 @@ function Link(props) {
702
848
  return;
703
849
  }
704
850
  evt.preventDefault();
705
- router.navigate(local.routeName, local.routeParams, local.routeOptions).catch(() => {});
851
+ navigateWithHash(router, local.routeName, local.routeParams, local.hash, local.routeOptions).catch(() => {});
706
852
  };
707
853
  const finalClassName = createMemo(() => buildActiveClassName(isActive(), local.activeClassName, local.class));
708
854
  return (() => {
@@ -1 +1 @@
1
- {"version":3,"file":"Link.d.ts","sourceRoot":"","sources":["../../../src/components/Link.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAEpC,wBAAgB,IAAI,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAC5C,KAAK,EAAE,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,GAC5B,GAAG,CAAC,OAAO,CAoFb"}
1
+ {"version":3,"file":"Link.d.ts","sourceRoot":"","sources":["../../../src/components/Link.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAEpC,wBAAgB,IAAI,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAC5C,KAAK,EAAE,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,GAC5B,GAAG,CAAC,OAAO,CAkHb"}
@@ -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";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAEhE,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAE5D,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAE9D,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,OAAO,EACL,cAAc,EACd,SAAS,EACT,oBAAoB,EACpB,YAAY,EACZ,aAAa,GACd,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAElE,YAAY,EACV,wBAAwB,EACxB,qBAAqB,GACtB,MAAM,qBAAqB,CAAC;AAE7B,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,YAAY,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAEhE,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAE5D,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAE9D,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,OAAO,EACL,cAAc,EACd,SAAS,EACT,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,aAAa,GACd,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAElE,YAAY,EACV,wBAAwB,EACxB,qBAAqB,GACtB,MAAM,qBAAqB,CAAC;AAE7B,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,YAAY,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC"}
@@ -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;
@@ -1 +1 @@
1
- {"version":3,"file":"link-utils.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/link-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAExD,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAQvD;AAID,wBAAgB,SAAS,CACvB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,GAClB,MAAM,GAAG,SAAS,CAoBpB;AAMD,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,OAAO,EACjB,eAAe,EAAE,MAAM,GAAG,SAAS,EACnC,aAAa,EAAE,MAAM,GAAG,SAAS,GAChC,MAAM,GAAG,SAAS,CAyBpB;AAED,wBAAgB,YAAY,CAC1B,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAwBT;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CAgB3E"}
1
+ {"version":3,"file":"link-utils.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/link-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,MAAM,EACN,MAAM,EACN,KAAK,EACN,MAAM,mBAAmB,CAAC;AAE3B,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAQvD;AAmBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAC1B,MAAM,GAAG,SAAS,CAiCpB;AA4BD,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,YAAY,CAAC,EAAE,iBAAiB,GAC/B,OAAO,CAAC,KAAK,CAAC,CAyBhB;AAMD,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,OAAO,EACjB,eAAe,EAAE,MAAM,GAAG,SAAS,EACnC,aAAa,EAAE,MAAM,GAAG,SAAS,GAChC,MAAM,GAAG,SAAS,CAyBpB;AAED,wBAAgB,YAAY,CAC1B,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAwBT;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CAgB3E"}
@@ -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 +1 @@
1
- {"version":3,"file":"scroll-restore.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/scroll-restore.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAS,MAAM,mBAAmB,CAAC;AAUvD,MAAM,MAAM,qBAAqB,GAAG,SAAS,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEjE,MAAM,WAAW,wBAAwB;IACvC,IAAI,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAC;IACzC,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACtC,eAAe,CAAC,EAAE,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,SAAS,CAAC;CAC1D;AAOD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,wBAAwB,GACjC;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CA+IzB"}
1
+ {"version":3,"file":"scroll-restore.d.ts","sourceRoot":"","sources":["../../../src/dom-utils/scroll-restore.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAS,MAAM,mBAAmB,CAAC;AAUvD,MAAM,MAAM,qBAAqB,GAAG,SAAS,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEjE,MAAM,WAAW,wBAAwB;IACvC,IAAI,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAC;IACzC,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACtC,eAAe,CAAC,EAAE,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,SAAS,CAAC;IACzD;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IACtC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAOD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,wBAAwB,GACjC;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAkMzB"}
@@ -11,6 +11,13 @@ export interface LinkProps<P extends Params = Params> extends Omit<JSX.HTMLAttri
11
11
  activeClassName?: string;
12
12
  activeStrict?: boolean;
13
13
  ignoreQueryParams?: boolean;
14
+ /**
15
+ * URL fragment (decoded form, no leading "#") (#532).
16
+ * - omitted/`undefined` → preserve current fragment on same-route navigation
17
+ * - `""` → clear fragment
18
+ * - non-empty → set fragment
19
+ */
20
+ hash?: string;
14
21
  target?: string;
15
22
  onClick?: (evt: MouseEvent) => void;
16
23
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1E,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAEpC,MAAM,WAAW,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACnD,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC;IAC5B,aAAa,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;CACnC;AAED,MAAM,WAAW,SAAS,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,IAAI,CAChE,GAAG,CAAC,cAAc,CAAC,iBAAiB,CAAC,EACrC,SAAS,CACV;IACC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,CAAC,CAAC;IAChB,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,UAAU,KAAK,IAAI,CAAC;CACrC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1E,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAEpC,MAAM,WAAW,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACnD,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC;IAC5B,aAAa,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;CACnC;AAED,MAAM,WAAW,SAAS,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,IAAI,CAChE,GAAG,CAAC,cAAc,CAAC,iBAAiB,CAAC,EACrC,SAAS,CACV;IACC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,CAAC,CAAC;IAChB,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,UAAU,KAAK,IAAI,CAAC;CACrC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-router/solid",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "type": "commonjs",
5
5
  "description": "Solid.js integration for Real-Router",
6
6
  "main": "./dist/cjs/index.js",
@@ -53,7 +53,7 @@
53
53
  "dependencies": {
54
54
  "@real-router/core": "^0.51.0",
55
55
  "@real-router/route-utils": "^0.2.2",
56
- "@real-router/sources": "^0.7.3"
56
+ "@real-router/sources": "^0.8.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@babel/core": "7.29.0",
@@ -71,7 +71,7 @@
71
71
  "solid-js": "1.9.12",
72
72
  "vite-plugin-solid": "2.11.11",
73
73
  "vitest": "4.1.0",
74
- "@real-router/browser-plugin": "^0.16.1"
74
+ "@real-router/browser-plugin": "^0.17.0"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "solid-js": ">=1.7.0"
@@ -4,7 +4,12 @@ import { createMemo, mergeProps, splitProps, useContext } from "solid-js";
4
4
  import { EMPTY_PARAMS, EMPTY_OPTIONS } from "../constants";
5
5
  import { RouterContext } from "../context";
6
6
  import { createSignalFromSource } from "../createSignalFromSource";
7
- import { shouldNavigate, buildHref, buildActiveClassName } from "../dom-utils";
7
+ import {
8
+ shouldNavigate,
9
+ buildHref,
10
+ buildActiveClassName,
11
+ navigateWithHash,
12
+ } from "../dom-utils";
8
13
 
9
14
  import type { LinkProps } from "../types";
10
15
  import type { Params } from "@real-router/core";
@@ -31,6 +36,7 @@ export function Link<P extends Params = Params>(
31
36
  "activeClassName",
32
37
  "activeStrict",
33
38
  "ignoreQueryParams",
39
+ "hash",
34
40
  "onClick",
35
41
  "target",
36
42
  "class",
@@ -45,22 +51,47 @@ export function Link<P extends Params = Params>(
45
51
 
46
52
  const router = ctx.router;
47
53
 
54
+ // Hash-aware active state (#532). `routeSelector` (the O(1) shared selector)
55
+ // doesn't know about hash — when `hash` prop is set, fall back to the slow
56
+ // path so the source's hash comparison kicks in. Tab-style UI is opt-in via
57
+ // the prop, so the fast path stays open for the typical Link case.
48
58
  const useFastPath =
59
+ local.hash === undefined &&
49
60
  !local.activeStrict &&
50
61
  local.ignoreQueryParams &&
51
62
  local.routeParams === EMPTY_PARAMS;
52
63
 
64
+ const buildActiveOptions = () => {
65
+ const base = {
66
+ strict: local.activeStrict,
67
+ ignoreQueryParams: local.ignoreQueryParams,
68
+ };
69
+
70
+ if (local.hash === undefined) {
71
+ return base;
72
+ }
73
+
74
+ return { ...base, hash: local.hash };
75
+ };
76
+
53
77
  const isActive = useFastPath
54
78
  ? () => ctx.routeSelector(local.routeName)
55
79
  : createSignalFromSource(
56
- createActiveRouteSource(router, local.routeName, local.routeParams, {
57
- strict: local.activeStrict,
58
- ignoreQueryParams: local.ignoreQueryParams,
59
- }),
80
+ createActiveRouteSource(
81
+ router,
82
+ local.routeName,
83
+ local.routeParams,
84
+ buildActiveOptions(),
85
+ ),
60
86
  );
61
87
 
62
88
  const href = createMemo(() =>
63
- buildHref(router, local.routeName, local.routeParams),
89
+ buildHref(
90
+ router,
91
+ local.routeName,
92
+ local.routeParams,
93
+ local.hash === undefined ? undefined : { hash: local.hash },
94
+ ),
64
95
  );
65
96
 
66
97
  const handleClick = (evt: MouseEvent) => {
@@ -77,9 +108,13 @@ export function Link<P extends Params = Params>(
77
108
  }
78
109
 
79
110
  evt.preventDefault();
80
- router
81
- .navigate(local.routeName, local.routeParams, local.routeOptions)
82
- .catch(() => {});
111
+ navigateWithHash(
112
+ router,
113
+ local.routeName,
114
+ local.routeParams,
115
+ local.hash,
116
+ local.routeOptions,
117
+ ).catch(() => {});
83
118
  };
84
119
 
85
120
  const finalClassName = createMemo(() =>
package/src/types.ts CHANGED
@@ -16,6 +16,13 @@ export interface LinkProps<P extends Params = Params> extends Omit<
16
16
  activeClassName?: string;
17
17
  activeStrict?: boolean;
18
18
  ignoreQueryParams?: boolean;
19
+ /**
20
+ * URL fragment (decoded form, no leading "#") (#532).
21
+ * - omitted/`undefined` → preserve current fragment on same-route navigation
22
+ * - `""` → clear fragment
23
+ * - non-empty → set fragment
24
+ */
25
+ hash?: string;
19
26
  target?: string;
20
27
  onClick?: (evt: MouseEvent) => void;
21
28
  }