@real-router/solid 0.9.0 → 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
@@ -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.
@@ -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
  }
package/dist/cjs/index.js CHANGED
@@ -378,12 +378,31 @@ function createScrollRestoration(router, options) {
378
378
  globalThis.scrollTo(0, top);
379
379
  }
380
380
  };
381
- const scrollToHashOrTop = () => {
381
+ const scrollToHashOrTop = route => {
382
+ // URL plugin path (#532): `state.context.url.hash` is the source of truth
383
+ // when one of the URL plugins (browser-plugin / navigation-plugin) is
384
+ // installed. The value is already DECODED — feeding it through
385
+ // `decodeURIComponent` again would throw on a bare `%`.
386
+ const ctxHash = route.context?.url?.hash;
387
+ if (ctxHash !== undefined) {
388
+ if (anchorEnabled && ctxHash.length > 0) {
389
+ // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
390
+ const element = document.getElementById(ctxHash);
391
+ if (element) {
392
+ element.scrollIntoView();
393
+ return;
394
+ }
395
+ }
396
+ writePos(0);
397
+ return;
398
+ }
399
+
400
+ // Fallback path: no URL plugin, read the DOM. `location.hash` is
401
+ // percent-encoded; ids in the DOM are the raw string, so decode for the
402
+ // match. Fall back to the raw slice if the hash contains a malformed
403
+ // escape sequence (decodeURIComponent throws on those).
382
404
  const hash = globalThis.location.hash;
383
405
  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
406
  let id;
388
407
  try {
389
408
  id = decodeURIComponent(hash.slice(1));
@@ -421,7 +440,7 @@ function createScrollRestoration(router, options) {
421
440
  return;
422
441
  }
423
442
  if (mode === "top" || !nav) {
424
- scrollToHashOrTop();
443
+ scrollToHashOrTop(route);
425
444
  return;
426
445
  }
427
446
  if (nav.navigationType === "replace") {
@@ -431,7 +450,7 @@ function createScrollRestoration(router, options) {
431
450
  writePos(loadStore()[keyOf(route)] ?? 0);
432
451
  return;
433
452
  }
434
- scrollToHashOrTop();
453
+ scrollToHashOrTop(route);
435
454
  });
436
455
  });
437
456
  const onPageHide = () => {
@@ -618,21 +637,94 @@ function createViewTransitions(router) {
618
637
  function shouldNavigate(evt) {
619
638
  return evt.button === 0 && !evt.metaKey && !evt.altKey && !evt.ctrlKey && !evt.shiftKey;
620
639
  }
621
- function buildHref(router, routeName, routeParams) {
640
+
641
+ /**
642
+ * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
643
+ * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
644
+ * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
645
+ * `shared/browser-env/url-context.ts` — duplicated here because the
646
+ * shared/dom-utils symlink graph does not reach shared/browser-env.
647
+ */
648
+ function encodeFragmentInline(decoded) {
649
+ return encodeURI(decoded).replaceAll("#", "%23");
650
+ }
651
+ /**
652
+ * Builds an href for a `<Link>` element.
653
+ *
654
+ * - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
655
+ * hash-plugin) when present.
656
+ * - Falls back to `router.buildPath` for runtimes without a URL plugin
657
+ * (memory-plugin, console UIs, NativeScript). In that fallback the hash
658
+ * is appended manually so the rendered href is still correct.
659
+ * - The optional 4th argument is an options object so the contract stays
660
+ * extensible. The `hash` option is a decoded fragment without leading "#";
661
+ * `<Link hash="#section">` is accepted defensively (leading "#" stripped).
662
+ * Frozen API: previous 3-arg call sites continue to work unchanged.
663
+ */
664
+ function buildHref(router, routeName, routeParams, options) {
622
665
  try {
666
+ const rawHash = options?.hash;
667
+ let normHash;
668
+ if (rawHash !== undefined) {
669
+ normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
670
+ }
623
671
  const buildUrl = router.buildUrl;
624
672
  if (buildUrl) {
625
- const url = buildUrl(routeName, routeParams);
673
+ const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : {
674
+ hash: normHash
675
+ });
626
676
  if (url !== undefined) {
627
677
  return url;
628
678
  }
629
679
  }
630
- return router.buildPath(routeName, routeParams);
680
+ const path = router.buildPath(routeName, routeParams);
681
+ return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
631
682
  } catch {
632
683
  console.error(`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`);
633
684
  return undefined;
634
685
  }
635
686
  }
687
+
688
+ /**
689
+ * `<Link>` click-handler navigation helper (#532).
690
+ *
691
+ * Wraps `router.navigate(name, params, opts)` with same-route different-hash
692
+ * detection: when the consumer clicks a hash-bearing Link that targets the
693
+ * current route with the same params but a different fragment, core's
694
+ * SAME_STATES check would otherwise reject the navigation. The helper adds
695
+ * `force: true` and `hashChange: true` automatically — subscribers can then
696
+ * disambiguate via `state.context.url.hashChanged`.
697
+ *
698
+ * For pure programmatic same-route hash-only navigation, callers are
699
+ * documented to pass `{ force: true }` themselves; the auto-bypass here is
700
+ * a UX convenience for `<Link hash>` that all 6 framework adapters share.
701
+ */
702
+ /**
703
+ * Local extended-options type. Adapters that depend only on `@real-router/core`
704
+ * (without a URL plugin) do not see the `NavigationOptions` augmentation that
705
+ * declares `hash` / `hashChange`. Casting to this widened type inside the
706
+ * helper keeps shared/dom-utils self-contained — adapters do not need to
707
+ * augment NavigationOptions themselves to consume `<Link hash>`.
708
+ */
709
+
710
+ function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
711
+ const opts = {
712
+ ...extraOptions
713
+ };
714
+ if (hash !== undefined) {
715
+ opts.hash = hash;
716
+ }
717
+ const current = router.getState();
718
+ if (current?.name === routeName && shallowEqual(current.params, routeParams)) {
719
+ const currentHash = current.context?.url?.hash ?? "";
720
+ const newHash = hash ?? currentHash;
721
+ if (currentHash !== newHash) {
722
+ opts.force = true;
723
+ opts.hashChange = true;
724
+ }
725
+ }
726
+ return router.navigate(routeName, routeParams, opts);
727
+ }
636
728
  function parseTokens(value) {
637
729
  return value ? value.match(/\S+/g) ?? [] : [];
638
730
  }
@@ -657,6 +749,26 @@ function buildActiveClassName(isActive, activeClassName, baseClassName) {
657
749
  }
658
750
  return baseClassName ?? undefined;
659
751
  }
752
+ function shallowEqual(prev, next) {
753
+ if (Object.is(prev, next)) {
754
+ return true;
755
+ }
756
+ if (!prev || !next) {
757
+ return false;
758
+ }
759
+ const prevKeys = Object.keys(prev);
760
+ if (prevKeys.length !== Object.keys(next).length) {
761
+ return false;
762
+ }
763
+ const prevRecord = prev;
764
+ const nextRecord = next;
765
+ for (const key of prevKeys) {
766
+ if (!Object.is(prevRecord[key], nextRecord[key])) {
767
+ return false;
768
+ }
769
+ }
770
+ return true;
771
+ }
660
772
  function applyLinkA11y(element) {
661
773
  if (!element) {
662
774
  return;
@@ -681,18 +793,35 @@ function Link(props) {
681
793
  activeStrict: false,
682
794
  ignoreQueryParams: true
683
795
  }, props);
684
- const [local, rest] = solidJs.splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "onClick", "target", "class", "children"]);
796
+ const [local, rest] = solidJs.splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "hash", "onClick", "target", "class", "children"]);
685
797
  const ctx = solidJs.useContext(RouterContext);
686
798
  if (!ctx) {
687
799
  throw new Error("Link must be used within a RouterProvider");
688
800
  }
689
801
  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
802
+
803
+ // Hash-aware active state (#532). `routeSelector` (the O(1) shared selector)
804
+ // doesn't know about hash — when `hash` prop is set, fall back to the slow
805
+ // path so the source's hash comparison kicks in. Tab-style UI is opt-in via
806
+ // the prop, so the fast path stays open for the typical Link case.
807
+ const useFastPath = local.hash === undefined && !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
808
+ const buildActiveOptions = () => {
809
+ const base = {
810
+ strict: local.activeStrict,
811
+ ignoreQueryParams: local.ignoreQueryParams
812
+ };
813
+ if (local.hash === undefined) {
814
+ return base;
815
+ }
816
+ return {
817
+ ...base,
818
+ hash: local.hash
819
+ };
820
+ };
821
+ const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(sources.createActiveRouteSource(router, local.routeName, local.routeParams, buildActiveOptions()));
822
+ const href = solidJs.createMemo(() => buildHref(router, local.routeName, local.routeParams, local.hash === undefined ? undefined : {
823
+ hash: local.hash
694
824
  }));
695
- const href = solidJs.createMemo(() => buildHref(router, local.routeName, local.routeParams));
696
825
  const handleClick = evt => {
697
826
  if (local.onClick) {
698
827
  local.onClick(evt);
@@ -704,7 +833,7 @@ function Link(props) {
704
833
  return;
705
834
  }
706
835
  evt.preventDefault();
707
- router.navigate(local.routeName, local.routeParams, local.routeOptions).catch(() => {});
836
+ navigateWithHash(router, local.routeName, local.routeParams, local.hash, local.routeOptions).catch(() => {});
708
837
  };
709
838
  const finalClassName = solidJs.createMemo(() => buildActiveClassName(isActive(), local.activeClassName, local.class));
710
839
  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
  }
@@ -376,12 +376,31 @@ function createScrollRestoration(router, options) {
376
376
  globalThis.scrollTo(0, top);
377
377
  }
378
378
  };
379
- const scrollToHashOrTop = () => {
379
+ const scrollToHashOrTop = route => {
380
+ // URL plugin path (#532): `state.context.url.hash` is the source of truth
381
+ // when one of the URL plugins (browser-plugin / navigation-plugin) is
382
+ // installed. The value is already DECODED — feeding it through
383
+ // `decodeURIComponent` again would throw on a bare `%`.
384
+ const ctxHash = route.context?.url?.hash;
385
+ if (ctxHash !== undefined) {
386
+ if (anchorEnabled && ctxHash.length > 0) {
387
+ // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
388
+ const element = document.getElementById(ctxHash);
389
+ if (element) {
390
+ element.scrollIntoView();
391
+ return;
392
+ }
393
+ }
394
+ writePos(0);
395
+ return;
396
+ }
397
+
398
+ // Fallback path: no URL plugin, read the DOM. `location.hash` is
399
+ // percent-encoded; ids in the DOM are the raw string, so decode for the
400
+ // match. Fall back to the raw slice if the hash contains a malformed
401
+ // escape sequence (decodeURIComponent throws on those).
380
402
  const hash = globalThis.location.hash;
381
403
  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
404
  let id;
386
405
  try {
387
406
  id = decodeURIComponent(hash.slice(1));
@@ -419,7 +438,7 @@ function createScrollRestoration(router, options) {
419
438
  return;
420
439
  }
421
440
  if (mode === "top" || !nav) {
422
- scrollToHashOrTop();
441
+ scrollToHashOrTop(route);
423
442
  return;
424
443
  }
425
444
  if (nav.navigationType === "replace") {
@@ -429,7 +448,7 @@ function createScrollRestoration(router, options) {
429
448
  writePos(loadStore()[keyOf(route)] ?? 0);
430
449
  return;
431
450
  }
432
- scrollToHashOrTop();
451
+ scrollToHashOrTop(route);
433
452
  });
434
453
  });
435
454
  const onPageHide = () => {
@@ -616,21 +635,94 @@ function createViewTransitions(router) {
616
635
  function shouldNavigate(evt) {
617
636
  return evt.button === 0 && !evt.metaKey && !evt.altKey && !evt.ctrlKey && !evt.shiftKey;
618
637
  }
619
- function buildHref(router, routeName, routeParams) {
638
+
639
+ /**
640
+ * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
641
+ * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
642
+ * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
643
+ * `shared/browser-env/url-context.ts` — duplicated here because the
644
+ * shared/dom-utils symlink graph does not reach shared/browser-env.
645
+ */
646
+ function encodeFragmentInline(decoded) {
647
+ return encodeURI(decoded).replaceAll("#", "%23");
648
+ }
649
+ /**
650
+ * Builds an href for a `<Link>` element.
651
+ *
652
+ * - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
653
+ * hash-plugin) when present.
654
+ * - Falls back to `router.buildPath` for runtimes without a URL plugin
655
+ * (memory-plugin, console UIs, NativeScript). In that fallback the hash
656
+ * is appended manually so the rendered href is still correct.
657
+ * - The optional 4th argument is an options object so the contract stays
658
+ * extensible. The `hash` option is a decoded fragment without leading "#";
659
+ * `<Link hash="#section">` is accepted defensively (leading "#" stripped).
660
+ * Frozen API: previous 3-arg call sites continue to work unchanged.
661
+ */
662
+ function buildHref(router, routeName, routeParams, options) {
620
663
  try {
664
+ const rawHash = options?.hash;
665
+ let normHash;
666
+ if (rawHash !== undefined) {
667
+ normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
668
+ }
621
669
  const buildUrl = router.buildUrl;
622
670
  if (buildUrl) {
623
- const url = buildUrl(routeName, routeParams);
671
+ const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : {
672
+ hash: normHash
673
+ });
624
674
  if (url !== undefined) {
625
675
  return url;
626
676
  }
627
677
  }
628
- return router.buildPath(routeName, routeParams);
678
+ const path = router.buildPath(routeName, routeParams);
679
+ return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
629
680
  } catch {
630
681
  console.error(`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`);
631
682
  return undefined;
632
683
  }
633
684
  }
685
+
686
+ /**
687
+ * `<Link>` click-handler navigation helper (#532).
688
+ *
689
+ * Wraps `router.navigate(name, params, opts)` with same-route different-hash
690
+ * detection: when the consumer clicks a hash-bearing Link that targets the
691
+ * current route with the same params but a different fragment, core's
692
+ * SAME_STATES check would otherwise reject the navigation. The helper adds
693
+ * `force: true` and `hashChange: true` automatically — subscribers can then
694
+ * disambiguate via `state.context.url.hashChanged`.
695
+ *
696
+ * For pure programmatic same-route hash-only navigation, callers are
697
+ * documented to pass `{ force: true }` themselves; the auto-bypass here is
698
+ * a UX convenience for `<Link hash>` that all 6 framework adapters share.
699
+ */
700
+ /**
701
+ * Local extended-options type. Adapters that depend only on `@real-router/core`
702
+ * (without a URL plugin) do not see the `NavigationOptions` augmentation that
703
+ * declares `hash` / `hashChange`. Casting to this widened type inside the
704
+ * helper keeps shared/dom-utils self-contained — adapters do not need to
705
+ * augment NavigationOptions themselves to consume `<Link hash>`.
706
+ */
707
+
708
+ function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
709
+ const opts = {
710
+ ...extraOptions
711
+ };
712
+ if (hash !== undefined) {
713
+ opts.hash = hash;
714
+ }
715
+ const current = router.getState();
716
+ if (current?.name === routeName && shallowEqual(current.params, routeParams)) {
717
+ const currentHash = current.context?.url?.hash ?? "";
718
+ const newHash = hash ?? currentHash;
719
+ if (currentHash !== newHash) {
720
+ opts.force = true;
721
+ opts.hashChange = true;
722
+ }
723
+ }
724
+ return router.navigate(routeName, routeParams, opts);
725
+ }
634
726
  function parseTokens(value) {
635
727
  return value ? value.match(/\S+/g) ?? [] : [];
636
728
  }
@@ -655,6 +747,26 @@ function buildActiveClassName(isActive, activeClassName, baseClassName) {
655
747
  }
656
748
  return baseClassName ?? undefined;
657
749
  }
750
+ function shallowEqual(prev, next) {
751
+ if (Object.is(prev, next)) {
752
+ return true;
753
+ }
754
+ if (!prev || !next) {
755
+ return false;
756
+ }
757
+ const prevKeys = Object.keys(prev);
758
+ if (prevKeys.length !== Object.keys(next).length) {
759
+ return false;
760
+ }
761
+ const prevRecord = prev;
762
+ const nextRecord = next;
763
+ for (const key of prevKeys) {
764
+ if (!Object.is(prevRecord[key], nextRecord[key])) {
765
+ return false;
766
+ }
767
+ }
768
+ return true;
769
+ }
658
770
  function applyLinkA11y(element) {
659
771
  if (!element) {
660
772
  return;
@@ -679,18 +791,35 @@ function Link(props) {
679
791
  activeStrict: false,
680
792
  ignoreQueryParams: true
681
793
  }, props);
682
- const [local, rest] = splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "onClick", "target", "class", "children"]);
794
+ const [local, rest] = splitProps(merged, ["routeName", "routeParams", "routeOptions", "activeClassName", "activeStrict", "ignoreQueryParams", "hash", "onClick", "target", "class", "children"]);
683
795
  const ctx = useContext(RouterContext);
684
796
  if (!ctx) {
685
797
  throw new Error("Link must be used within a RouterProvider");
686
798
  }
687
799
  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
800
+
801
+ // Hash-aware active state (#532). `routeSelector` (the O(1) shared selector)
802
+ // doesn't know about hash — when `hash` prop is set, fall back to the slow
803
+ // path so the source's hash comparison kicks in. Tab-style UI is opt-in via
804
+ // the prop, so the fast path stays open for the typical Link case.
805
+ const useFastPath = local.hash === undefined && !local.activeStrict && local.ignoreQueryParams && local.routeParams === EMPTY_PARAMS;
806
+ const buildActiveOptions = () => {
807
+ const base = {
808
+ strict: local.activeStrict,
809
+ ignoreQueryParams: local.ignoreQueryParams
810
+ };
811
+ if (local.hash === undefined) {
812
+ return base;
813
+ }
814
+ return {
815
+ ...base,
816
+ hash: local.hash
817
+ };
818
+ };
819
+ const isActive = useFastPath ? () => ctx.routeSelector(local.routeName) : createSignalFromSource(createActiveRouteSource(router, local.routeName, local.routeParams, buildActiveOptions()));
820
+ const href = createMemo(() => buildHref(router, local.routeName, local.routeParams, local.hash === undefined ? undefined : {
821
+ hash: local.hash
692
822
  }));
693
- const href = createMemo(() => buildHref(router, local.routeName, local.routeParams));
694
823
  const handleClick = evt => {
695
824
  if (local.onClick) {
696
825
  local.onClick(evt);
@@ -702,7 +831,7 @@ function Link(props) {
702
831
  return;
703
832
  }
704
833
  evt.preventDefault();
705
- router.navigate(local.routeName, local.routeParams, local.routeOptions).catch(() => {});
834
+ navigateWithHash(router, local.routeName, local.routeParams, local.hash, local.routeOptions).catch(() => {});
706
835
  };
707
836
  const finalClassName = createMemo(() => buildActiveClassName(isActive(), local.activeClassName, local.class));
708
837
  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 +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;CAC1D;AAOD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,wBAAwB,GACjC;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAwKzB"}
@@ -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.0",
3
+ "version": "0.10.0",
4
4
  "type": "commonjs",
5
5
  "description": "Solid.js integration for Real-Router",
6
6
  "main": "./dist/cjs/index.js",
@@ -51,9 +51,9 @@
51
51
  "license": "MIT",
52
52
  "sideEffects": false,
53
53
  "dependencies": {
54
- "@real-router/core": "^0.50.2",
55
- "@real-router/route-utils": "^0.2.1",
56
- "@real-router/sources": "^0.7.2"
54
+ "@real-router/core": "^0.51.0",
55
+ "@real-router/route-utils": "^0.2.2",
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.0"
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
  }