@real-router/solid 0.11.1 → 0.12.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.
Files changed (81) hide show
  1. package/README.md +169 -14
  2. package/dist/cjs/index.d.ts +20 -1
  3. package/dist/cjs/index.js +0 -0
  4. package/dist/cjs/ssr.d.ts +163 -0
  5. package/dist/cjs/ssr.js +263 -0
  6. package/dist/esm/index.d.mts +20 -1
  7. package/dist/esm/index.mjs +0 -0
  8. package/dist/esm/ssr.d.mts +163 -0
  9. package/dist/esm/ssr.mjs +254 -0
  10. package/dist/types/RouterProvider.d.ts.map +1 -1
  11. package/dist/types/components/Await.d.ts +30 -0
  12. package/dist/types/components/Await.d.ts.map +1 -0
  13. package/dist/types/components/ClientOnly.d.ts +7 -0
  14. package/dist/types/components/ClientOnly.d.ts.map +1 -0
  15. package/dist/types/components/HttpStatusCode.d.ts +52 -0
  16. package/dist/types/components/HttpStatusCode.d.ts.map +1 -0
  17. package/dist/types/components/HttpStatusProvider.d.ts +9 -0
  18. package/dist/types/components/HttpStatusProvider.d.ts.map +1 -0
  19. package/dist/types/components/Link.d.ts.map +1 -1
  20. package/dist/types/components/RouteView/RouteView.d.ts.map +1 -1
  21. package/dist/types/components/RouteView/components.d.ts.map +1 -1
  22. package/dist/types/components/RouteView/helpers.d.ts.map +1 -1
  23. package/dist/types/components/ServerOnly.d.ts +7 -0
  24. package/dist/types/components/ServerOnly.d.ts.map +1 -0
  25. package/dist/types/components/Streamed.d.ts +18 -0
  26. package/dist/types/components/Streamed.d.ts.map +1 -0
  27. package/dist/types/constants.d.ts +20 -2
  28. package/dist/types/constants.d.ts.map +1 -1
  29. package/dist/types/context.d.ts +9 -0
  30. package/dist/types/context.d.ts.map +1 -1
  31. package/dist/types/createSignalFromSource.d.ts.map +1 -1
  32. package/dist/types/createStoreFromSource.d.ts +19 -0
  33. package/dist/types/createStoreFromSource.d.ts.map +1 -1
  34. package/dist/types/directives/link.d.ts.map +1 -1
  35. package/dist/types/dom-utils/__test-helpers/expected-fragment.d.ts +31 -0
  36. package/dist/types/dom-utils/__test-helpers/expected-fragment.d.ts.map +1 -0
  37. package/dist/types/dom-utils/__test-helpers/index.d.ts +9 -0
  38. package/dist/types/dom-utils/__test-helpers/index.d.ts.map +1 -0
  39. package/dist/types/dom-utils/link-utils.d.ts +23 -0
  40. package/dist/types/dom-utils/link-utils.d.ts.map +1 -1
  41. package/dist/types/dom-utils/route-announcer.d.ts.map +1 -1
  42. package/dist/types/dom-utils/scroll-restore.d.ts +38 -1
  43. package/dist/types/dom-utils/scroll-restore.d.ts.map +1 -1
  44. package/dist/types/hooks/useDeferred.d.ts +16 -0
  45. package/dist/types/hooks/useDeferred.d.ts.map +1 -0
  46. package/dist/types/hooks/useNavigator.d.ts.map +1 -1
  47. package/dist/types/hooks/useRouteUtils.d.ts.map +1 -1
  48. package/dist/types/hooks/useRouter.d.ts.map +1 -1
  49. package/dist/types/index.d.ts +1 -0
  50. package/dist/types/index.d.ts.map +1 -1
  51. package/dist/types/ssr.d.ts +16 -0
  52. package/dist/types/ssr.d.ts.map +1 -0
  53. package/dist/types/utils/createHttpStatusSink.d.ts +29 -0
  54. package/dist/types/utils/createHttpStatusSink.d.ts.map +1 -0
  55. package/dist/types/utils/createMountedSignal.d.ts +16 -0
  56. package/dist/types/utils/createMountedSignal.d.ts.map +1 -0
  57. package/package.json +20 -4
  58. package/src/RouterProvider.tsx +36 -30
  59. package/src/components/Await.tsx +56 -0
  60. package/src/components/ClientOnly.tsx +20 -0
  61. package/src/components/HttpStatusCode.tsx +65 -0
  62. package/src/components/HttpStatusProvider.tsx +21 -0
  63. package/src/components/Link.tsx +21 -15
  64. package/src/components/RouteView/RouteView.tsx +19 -18
  65. package/src/components/RouteView/components.tsx +34 -28
  66. package/src/components/RouteView/helpers.tsx +0 -0
  67. package/src/components/ServerOnly.tsx +20 -0
  68. package/src/components/Streamed.tsx +23 -0
  69. package/src/constants.ts +20 -2
  70. package/src/context.ts +21 -1
  71. package/src/createSignalFromSource.ts +48 -5
  72. package/src/createStoreFromSource.ts +49 -2
  73. package/src/directives/link.tsx +41 -16
  74. package/src/hooks/useDeferred.tsx +36 -0
  75. package/src/hooks/useNavigator.tsx +3 -12
  76. package/src/hooks/useRouteUtils.tsx +39 -1
  77. package/src/hooks/useRouter.tsx +3 -12
  78. package/src/index.tsx +2 -0
  79. package/src/ssr.tsx +39 -0
  80. package/src/utils/createHttpStatusSink.ts +31 -0
  81. package/src/utils/createMountedSignal.ts +26 -0
@@ -30,50 +30,56 @@ export interface NotFoundMarker {
30
30
 
31
31
  export type RouteViewMarker = MatchMarker | SelfMarker | NotFoundMarker;
32
32
 
33
- export function Match(props: MatchProps): JSX.Element {
34
- const result: MatchMarker = {
35
- $$type: MATCH_MARKER,
36
- segment: props.segment,
37
- exact: props.exact ?? false,
38
- fallback: props.fallback,
33
+ // §8.1 audit fix (LOW #8) three marker factories share the
34
+ // `$$type + children getter` skeleton plus a small per-marker payload.
35
+ // `createMarker` keeps the shared shape in one place; each public factory
36
+ // (Match/Self/NotFound) only provides what differs.
37
+ //
38
+ // The `children` getter (not a plain field) is intentional: it lets the
39
+ // marker capture Solid's reactive `props.children` lazily, so swapping the
40
+ // marker content in a parent component re-evaluates at render time without
41
+ // pulling stale references.
42
+ //
43
+ // Marker objects are identified by `$$type` Symbol in RouteView/helpers.tsx,
44
+ // not rendered as JSX. The `as unknown as JSX.Element` cast is required at
45
+ // the call site because `JSX.Element` does not include arbitrary marker
46
+ // shapes.
47
+ function createMarker<M extends RouteViewMarker>(
48
+ type: M["$$type"],
49
+ getChildren: () => JSX.Element,
50
+ extras?: Omit<M, "$$type" | "children">,
51
+ ): JSX.Element {
52
+ const result = {
53
+ $$type: type,
54
+ ...extras,
39
55
  get children(): JSX.Element {
40
- return props.children;
56
+ return getChildren();
41
57
  },
42
58
  };
43
59
 
44
- // Marker object is identified by $$type Symbol in RouteView/helpers.tsx,
45
- // not rendered as JSX. Cast required because JSX.Element does not include
46
- // arbitrary marker shapes.
47
60
  return result as unknown as JSX.Element;
48
61
  }
49
62
 
63
+ export function Match(props: MatchProps): JSX.Element {
64
+ return createMarker<MatchMarker>(MATCH_MARKER, () => props.children, {
65
+ segment: props.segment,
66
+ exact: props.exact ?? false,
67
+ fallback: props.fallback,
68
+ });
69
+ }
70
+
50
71
  Match.displayName = "RouteView.Match";
51
72
 
52
73
  export function Self(props: SelfProps): JSX.Element {
53
- const result: SelfMarker = {
54
- $$type: SELF_MARKER,
74
+ return createMarker<SelfMarker>(SELF_MARKER, () => props.children, {
55
75
  fallback: props.fallback,
56
- get children(): JSX.Element {
57
- return props.children;
58
- },
59
- };
60
-
61
- // See Match for the marker-pattern rationale.
62
- return result as unknown as JSX.Element;
76
+ });
63
77
  }
64
78
 
65
79
  Self.displayName = "RouteView.Self";
66
80
 
67
81
  export function NotFound(props: NotFoundProps): JSX.Element {
68
- const result: NotFoundMarker = {
69
- $$type: NOT_FOUND_MARKER,
70
- get children(): JSX.Element {
71
- return props.children;
72
- },
73
- };
74
-
75
- // See Match for the marker-pattern rationale.
76
- return result as unknown as JSX.Element;
82
+ return createMarker<NotFoundMarker>(NOT_FOUND_MARKER, () => props.children);
77
83
  }
78
84
 
79
85
  NotFound.displayName = "RouteView.NotFound";
@@ -0,0 +1,20 @@
1
+ import { Show } from "solid-js";
2
+
3
+ import { createMountedSignal } from "../utils/createMountedSignal";
4
+
5
+ import type { JSX } from "solid-js";
6
+
7
+ export interface ServerOnlyProps {
8
+ readonly children: JSX.Element;
9
+ readonly fallback?: JSX.Element;
10
+ }
11
+
12
+ export function ServerOnly(props: ServerOnlyProps): JSX.Element {
13
+ const mounted = createMountedSignal();
14
+
15
+ return (
16
+ <Show when={mounted()} fallback={props.children}>
17
+ {props.fallback}
18
+ </Show>
19
+ );
20
+ }
@@ -0,0 +1,23 @@
1
+ import { Suspense } from "solid-js";
2
+
3
+ import type { JSX } from "solid-js";
4
+
5
+ export interface StreamedProps {
6
+ /** Shown while any descendant `<Await>` / `createResource` suspends. */
7
+ readonly fallback: JSX.Element;
8
+ readonly children: JSX.Element;
9
+ }
10
+
11
+ /**
12
+ * Cross-adapter alias for Solid's `<Suspense fallback={…}>`. Symmetric naming
13
+ * with the React/Preact/Svelte/Vue/Angular `<Streamed>` components — pick
14
+ * `<Streamed>` for cross-framework consistency, or use Solid's native
15
+ * `<Suspense>` directly when team conventions prefer that.
16
+ *
17
+ * Solid's `<Suspense>` is a built-in primitive; out-of-order resolution +
18
+ * splice scripts during `renderToStream` are part of the runtime. See
19
+ * Solid's SSR docs for the wire-format details.
20
+ */
21
+ export function Streamed(props: StreamedProps): JSX.Element {
22
+ return <Suspense fallback={props.fallback}>{props.children}</Suspense>;
23
+ }
package/src/constants.ts CHANGED
@@ -1,9 +1,27 @@
1
1
  /**
2
- * Stable empty object for default params
2
+ * Stable empty object for default params.
3
+ *
4
+ * `Object.freeze` makes mutation throw under ESM strict mode — this guards
5
+ * against accidental writes that would corrupt the shared default across
6
+ * every Link without explicit params.
7
+ *
8
+ * §8.1 audit note (LOW #19): consumers cast `EMPTY_PARAMS as P` at usage
9
+ * sites (e.g. `Link.tsx`, `directives/link.tsx`). The cast is required for
10
+ * type compatibility with the generic `P extends Params` and DOES technically
11
+ * widen the `Readonly<{}>` type, but the underlying object stays frozen at
12
+ * runtime — any attempt to mutate fails at the JS engine level regardless
13
+ * of TS-level visibility. The frozen sentinel is also used by Link's
14
+ * fast-path identity check (`props.routeParams === undefined` after the
15
+ * §8.1 audit fix); changing this object's identity would silently break
16
+ * that path.
3
17
  */
4
18
  export const EMPTY_PARAMS = Object.freeze({});
5
19
 
6
20
  /**
7
- * Stable empty options object
21
+ * Stable empty options object.
22
+ *
23
+ * Same freeze/cast guarantees as `EMPTY_PARAMS` — the sentinel is shared
24
+ * across all default `routeOptions` consumers (`Link`, `use:link`) to
25
+ * avoid per-render `{}` allocations.
8
26
  */
9
27
  export const EMPTY_OPTIONS = Object.freeze({});
package/src/context.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createContext } from "solid-js";
1
+ import { createContext, useContext } from "solid-js";
2
2
 
3
3
  import type { RouteState } from "./types";
4
4
  import type { Router, Navigator } from "@real-router/core";
@@ -13,3 +13,23 @@ export interface RouterContextValue {
13
13
  export const RouterContext = createContext<RouterContextValue | null>(null);
14
14
 
15
15
  export const RouteContext = createContext<Accessor<RouteState> | null>(null);
16
+
17
+ /**
18
+ * Read the required RouterContext or throw a labelled error. Internal helper
19
+ * — consolidates 4 copies of the same `useContext + null-check + throw`
20
+ * block across the public hooks/components/directives. The `consumerName`
21
+ * parameter keeps each callsite's error message specific (so the consumer
22
+ * sees "useRouter must be used within a RouterProvider", not a generic
23
+ * "context missing" message).
24
+ */
25
+ export function useRequiredRouterContext(
26
+ consumerName: string,
27
+ ): RouterContextValue {
28
+ const ctx = useContext(RouterContext);
29
+
30
+ if (!ctx) {
31
+ throw new Error(`${consumerName} must be used within a RouterProvider`);
32
+ }
33
+
34
+ return ctx;
35
+ }
@@ -6,8 +6,42 @@ import type { Accessor } from "solid-js";
6
6
  export function createSignalFromSource<T>(
7
7
  source: RouterSource<T>,
8
8
  ): Accessor<T> {
9
- const [value, setValue] = createSignal<T>(source.getSnapshot());
9
+ // Mini-sprint E.5 (audit-5 §4.2 #7) — defensive init-phase snapshot
10
+ // reads. A throwing `getSnapshot()` during construction would
11
+ // propagate up through `createSignal<T>(...)` (or the post-subscribe
12
+ // re-sync below) into the reactive owner, tearing down the entire
13
+ // RouterProvider subtree (and any siblings sharing the owner). Catch
14
+ // + log + fall back to `undefined` (initial) or skip-update (post-
15
+ // subscribe re-sync) so the accessor still constructs; the next
16
+ // emit refreshes the value.
17
+ //
18
+ // Post-init emit-time throws are NOT wrapped — they bubble to Solid's
19
+ // `<ErrorBoundary>` (or surface as unhandled errors in dev) so
20
+ // genuine source bugs aren't silently masked.
21
+ let initial: T;
10
22
 
23
+ try {
24
+ initial = source.getSnapshot();
25
+ } catch (error) {
26
+ console.error(
27
+ "[real-router] createSignalFromSource: initial getSnapshot threw — accessor defaulting to undefined.",
28
+ error,
29
+ );
30
+ initial = undefined as T;
31
+ }
32
+
33
+ const [value, setValue] = createSignal<T>(initial);
34
+
35
+ // `sync` is a stable reference (defined once at outer scope) so the
36
+ // subscribe callback below does not re-allocate it per emit. Solid's
37
+ // `setValue(fn)` treats fn as an updater `(prev) => next`; our updater
38
+ // ignores `prev` and reads the latest snapshot fresh, which gives a
39
+ // function-form micro-allocation cost (one extra fn call per emit) BUT
40
+ // a much smaller TS surface than the `setValue(value)` direct form —
41
+ // that overload is typed `Exclude<T, Function>`, requiring per-call
42
+ // `as Exclude<T, (...args: never[]) => unknown>` casts for generic T.
43
+ // The micro-opt is not worth the cast complexity.
44
+ // See §8.2 audit note.
11
45
  const sync = (): T => source.getSnapshot();
12
46
 
13
47
  const unsubscribe = source.subscribe(() => {
@@ -15,10 +49,19 @@ export function createSignalFromSource<T>(
15
49
  });
16
50
 
17
51
  // Re-read after subscribe: lazy sources reconcile their snapshot in
18
- // onFirstSubscribe (when reused after disconnect via cache). Listener is not
19
- // notified for that internal update, so we must sync the signal manually.
20
- // No-op when snapshot is unchanged (signal equality check).
21
- setValue(sync);
52
+ // onFirstSubscribe (when reused after disconnect via cache). Listener
53
+ // is not notified for that internal update, so we must sync manually.
54
+ // No-op when snapshot is unchanged (signal equality check). Wrapped
55
+ // because this is still init-phase: a throw here ALSO tears down the
56
+ // owner, same as the initial read above.
57
+ try {
58
+ setValue(sync);
59
+ } catch (error) {
60
+ console.error(
61
+ "[real-router] createSignalFromSource: post-subscribe getSnapshot threw — accessor retains initial value.",
62
+ error,
63
+ );
64
+ }
22
65
 
23
66
  onCleanup(() => {
24
67
  unsubscribe();
@@ -3,15 +3,62 @@ import { createStore, reconcile } from "solid-js/store";
3
3
 
4
4
  import type { RouterSource } from "@real-router/sources";
5
5
 
6
+ /**
7
+ * Bridges a `RouterSource<T>` into a Solid store (`createStore` + `reconcile`).
8
+ *
9
+ * Unlike `createSignalFromSource` (whole-value replacement via `===`), this
10
+ * bridge uses `reconcile` on every emit so **unchanged nested paths retain
11
+ * their object identity**. Components that read only `state.route.name` will
12
+ * not re-run when `state.route.params` changes — granular reactivity without
13
+ * manual memoisation.
14
+ *
15
+ * **Ownership**: calls `onCleanup` — must be called inside a reactive owner
16
+ * (component body or `createRoot`). Same contract as `createSignalFromSource`.
17
+ *
18
+ * **Lazy-source re-sync**: after `source.subscribe()`, a cached lazy source
19
+ * may reconcile its snapshot in `onFirstSubscribe`. The listener is not
20
+ * notified for that internal update, so we re-read immediately after
21
+ * subscribing (`setState(reconcile(source.getSnapshot()))`) — mirrors the
22
+ * same pattern in `createSignalFromSource`. `reconcile` is a no-op when the
23
+ * snapshot is structurally unchanged, so there is no spurious reactivity cost.
24
+ */
6
25
  export function createStoreFromSource<T extends object>(
7
26
  source: RouterSource<T>,
8
27
  ): T {
9
- const [state, setState] = createStore<T>({ ...source.getSnapshot() });
28
+ const initialSnapshot = source.getSnapshot();
29
+ const [state, setState] = createStore<T>({ ...initialSnapshot });
30
+
31
+ // Track the last reconciled snapshot reference to short-circuit redundant
32
+ // `reconcile` calls. Cached lazy sources (e.g. `createRouteNodeSource`)
33
+ // stabilize their snapshot — the same reference flows through multiple
34
+ // emits when nothing in the node's slice changed. `reconcile` itself
35
+ // handles identity (no-ops on structurally-equal input), but a reference
36
+ // check is cheaper than the structural walk and avoids the function call
37
+ // entirely on every navigation × N store consumers (§8b H10 audit fix).
38
+ let lastSnapshot: T = initialSnapshot;
10
39
 
11
40
  const unsubscribe = source.subscribe(() => {
12
- setState(reconcile(source.getSnapshot()));
41
+ const nextSnapshot = source.getSnapshot();
42
+
43
+ if (nextSnapshot === lastSnapshot) {
44
+ return;
45
+ }
46
+
47
+ lastSnapshot = nextSnapshot;
48
+ setState(reconcile(nextSnapshot));
13
49
  });
14
50
 
51
+ // Re-read after subscribe: lazy sources reconcile their snapshot in
52
+ // onFirstSubscribe (when reused after disconnect via cache). The listener
53
+ // is not notified for that internal update, so we must reconcile manually.
54
+ // Guarded by the same reference check so a no-op stays free.
55
+ const afterSubscribe = source.getSnapshot();
56
+
57
+ if (afterSubscribe !== lastSnapshot) {
58
+ lastSnapshot = afterSubscribe;
59
+ setState(reconcile(afterSubscribe));
60
+ }
61
+
15
62
  onCleanup(unsubscribe);
16
63
 
17
64
  return state;
@@ -24,30 +24,38 @@ export function link<P extends Params = Params>(
24
24
  const router = useRouter();
25
25
  const options = accessor();
26
26
 
27
+ // audit-2026-05-17 §8a cleanup — single instanceof probe, single EMPTY_PARAMS
28
+ // default. Previously evaluated three times for the <a>-only branches and
29
+ // twice for routeParams. The directive accessor is read once at init
30
+ // (documented "use:link Options Are Captured Once"), so both lookups are
31
+ // stable and worth hoisting.
32
+ const anchor = element instanceof HTMLAnchorElement ? element : null;
33
+ const resolvedRouteParams = (options.routeParams ?? EMPTY_PARAMS) as P;
34
+ const resolvedRouteOptions = options.routeOptions ?? EMPTY_OPTIONS;
35
+
27
36
  // Set href on <a> elements
28
- if (element instanceof HTMLAnchorElement) {
29
- const href = buildHref(
30
- router,
31
- options.routeName,
32
- options.routeParams ?? (EMPTY_PARAMS as P),
33
- );
37
+ if (anchor) {
38
+ const href = buildHref(router, options.routeName, resolvedRouteParams);
34
39
 
35
40
  if (href === undefined) {
36
- element.removeAttribute("href");
41
+ anchor.removeAttribute("href");
37
42
  } else {
38
- element.href = href;
43
+ anchor.href = href;
39
44
  }
40
45
  }
41
46
 
42
47
  applyLinkA11y(element);
43
48
 
44
- // Active class tracking (reactive)
49
+ // Active class tracking: only `isActive` is reactive (createEffect toggles
50
+ // the class on each emit). The `options` object itself is captured ONCE at
51
+ // init (see gotcha "use:link Options Are Captured Once") — changing
52
+ // `activeClassName` / `routeName` / `routeParams` later has no effect.
45
53
  if (options.activeClassName) {
46
54
  const activeClassName = options.activeClassName;
47
55
  const activeSource = createActiveRouteSource(
48
56
  router,
49
57
  options.routeName,
50
- options.routeParams ?? (EMPTY_PARAMS as P),
58
+ resolvedRouteParams,
51
59
  {
52
60
  strict: options.activeStrict ?? false,
53
61
  ignoreQueryParams: options.ignoreQueryParams ?? true,
@@ -65,16 +73,33 @@ export function link<P extends Params = Params>(
65
73
  if (!shouldNavigate(evt)) {
66
74
  return;
67
75
  }
68
- if (element instanceof HTMLAnchorElement) {
76
+
77
+ // Mini-sprint E.2 (audit-5 §4.2 #2) — respect upstream
78
+ // preventDefault. `<Link>` checks `local.onClick(evt); if
79
+ // (evt.defaultPrevented) return;` because it owns the React-style
80
+ // onClick prop. The directive has no equivalent prop, but the
81
+ // consumer may register their OWN click listener on the same
82
+ // element (DOM event order is "addEventListener queue, in
83
+ // registration order"). If their listener called preventDefault
84
+ // to opt-out of navigation, the directive must honour that.
85
+ if (evt.defaultPrevented) {
86
+ return;
87
+ }
88
+
89
+ // Symmetric with <Link> (#P0.6 audit): on an <a target="_blank"> the
90
+ // browser opens the URL in a new tab/window natively. Intercepting the
91
+ // click via preventDefault + router.navigate would suppress the new
92
+ // tab and silently keep the user on the current page.
93
+ if (anchor?.target === "_blank") {
94
+ return;
95
+ }
96
+
97
+ if (anchor) {
69
98
  evt.preventDefault();
70
99
  }
71
100
 
72
101
  router
73
- .navigate(
74
- options.routeName,
75
- options.routeParams ?? (EMPTY_PARAMS as P),
76
- options.routeOptions ?? EMPTY_OPTIONS,
77
- )
102
+ .navigate(options.routeName, resolvedRouteParams, resolvedRouteOptions)
78
103
  .catch(() => {});
79
104
  }
80
105
 
@@ -0,0 +1,36 @@
1
+ import { useRoute } from "./useRoute";
2
+
3
+ import type { Accessor } from "solid-js";
4
+
5
+ interface DeferredContext {
6
+ ssrDataDeferred?: Record<string, Promise<unknown>>;
7
+ }
8
+
9
+ const NEVER_PROMISE = new Promise<never>(() => {
10
+ // Intentionally never resolves — surfaces a forever-pending Suspense boundary
11
+ // when a key is requested that the loader never declared.
12
+ });
13
+
14
+ /**
15
+ * Read a deferred promise published by `defer({ deferred: { <key>: Promise } })`
16
+ * inside an SSR data loader.
17
+ *
18
+ * Returns a Solid `Accessor<Promise<T>>` so the value tracks the active route
19
+ * — re-reading on navigation picks up the new state's deferred map. Wrap with
20
+ * `<Await name="key">{(value) => …}</Await>` (this package), which builds on
21
+ * `createResource` + `<Suspense>` for native Solid streaming.
22
+ *
23
+ * Returns a forever-pending promise when the key is missing — surfaces
24
+ * loader/consumer key drift as a visible Suspense fallback rather than a
25
+ * silent runtime error.
26
+ */
27
+ export function useDeferred<T = unknown>(key: string): Accessor<Promise<T>> {
28
+ const routeAccessor = useRoute();
29
+
30
+ return () => {
31
+ const context = routeAccessor().route.context as DeferredContext;
32
+ const deferred = context.ssrDataDeferred;
33
+
34
+ return (deferred?.[key] ?? NEVER_PROMISE) as Promise<T>;
35
+ };
36
+ }
@@ -1,15 +1,6 @@
1
- import { useContext } from "solid-js";
2
-
3
- import { RouterContext } from "../context";
1
+ import { useRequiredRouterContext } from "../context";
4
2
 
5
3
  import type { Navigator } from "@real-router/core";
6
4
 
7
- export const useNavigator = (): Navigator => {
8
- const ctx = useContext(RouterContext);
9
-
10
- if (!ctx) {
11
- throw new Error("useNavigator must be used within a RouterProvider");
12
- }
13
-
14
- return ctx.navigator;
15
- };
5
+ export const useNavigator = (): Navigator =>
6
+ useRequiredRouterContext("useNavigator").navigator;
@@ -3,10 +3,48 @@ import { getRouteUtils } from "@real-router/route-utils";
3
3
 
4
4
  import { useRouter } from "./useRouter";
5
5
 
6
+ import type { Router } from "@real-router/core";
6
7
  import type { RouteUtils } from "@real-router/route-utils";
7
8
 
9
+ // §8.1 audit fix (MED) — cache the `RouteUtils` per (router, tree) so N
10
+ // components calling `useRouteUtils()` against the same router share ONE
11
+ // chain through `getPluginApi(router).getTree()` + `getRouteUtils(tree)`.
12
+ //
13
+ // Why two-level (router + tree) and not just router:
14
+ // `getRouteUtils` is WeakMap-cached by tree root, BUT a RouteUtils
15
+ // instance is built FROM the tree at construction time — its internal
16
+ // `getChain`/`getSiblings` caches are pre-computed Object.freeze'd arrays
17
+ // (see `@real-router/route-utils` CLAUDE.md). If the router replaces its
18
+ // tree (e.g. `routesApi.replace([...])`), the old RouteUtils is stale.
19
+ // Caching only by router would serve the stale instance.
20
+ //
21
+ // Storing `{ tree, utils }` per router lets us detect tree replacement and
22
+ // recompute. In the steady state (no tree mutation), this is a cheap
23
+ // reference compare; under tree replacement, the cache self-heals on the
24
+ // next call.
25
+ //
26
+ // WeakMap keys on the router — entries are released automatically when the
27
+ // router is garbage-collected.
28
+ interface CachedEntry {
29
+ tree: unknown;
30
+ utils: RouteUtils;
31
+ }
32
+
33
+ const routeUtilsCache = new WeakMap<Router, CachedEntry>();
34
+
8
35
  export const useRouteUtils = (): RouteUtils => {
9
36
  const router = useRouter();
37
+ const tree = getPluginApi(router).getTree();
38
+
39
+ const cached = routeUtilsCache.get(router);
40
+
41
+ if (cached?.tree === tree) {
42
+ return cached.utils;
43
+ }
44
+
45
+ const utils = getRouteUtils(tree);
46
+
47
+ routeUtilsCache.set(router, { tree, utils });
10
48
 
11
- return getRouteUtils(getPluginApi(router).getTree());
49
+ return utils;
12
50
  };
@@ -1,15 +1,6 @@
1
- import { useContext } from "solid-js";
2
-
3
- import { RouterContext } from "../context";
1
+ import { useRequiredRouterContext } from "../context";
4
2
 
5
3
  import type { Router } from "@real-router/core";
6
4
 
7
- export const useRouter = (): Router => {
8
- const ctx = useContext(RouterContext);
9
-
10
- if (!ctx) {
11
- throw new Error("useRouter must be used within a RouterProvider");
12
- }
13
-
14
- return ctx.router;
15
- };
5
+ export const useRouter = (): Router =>
6
+ useRequiredRouterContext("useRouter").router;
package/src/index.tsx CHANGED
@@ -30,6 +30,8 @@ export { RouterProvider } from "./RouterProvider";
30
30
 
31
31
  export { RouterContext, RouteContext } from "./context";
32
32
 
33
+ export type { RouterContextValue } from "./context";
34
+
33
35
  export { createSignalFromSource } from "./createSignalFromSource";
34
36
 
35
37
  export { createStoreFromSource } from "./createStoreFromSource";
package/src/ssr.tsx ADDED
@@ -0,0 +1,39 @@
1
+ // SSR-feature entry — Solid 1.7+
2
+ //
3
+ // Server-side and SSR-aware components/hooks. Mirror of `@real-router/react/ssr`
4
+ // — same exports, Solid-native idioms (Accessor returns, createResource-backed
5
+ // Await, native Suspense for Streamed).
6
+
7
+ // Components
8
+ export { ClientOnly } from "./components/ClientOnly";
9
+
10
+ export { ServerOnly } from "./components/ServerOnly";
11
+
12
+ export { Await } from "./components/Await";
13
+
14
+ export { Streamed } from "./components/Streamed";
15
+
16
+ export { HttpStatusCode } from "./components/HttpStatusCode";
17
+
18
+ export { HttpStatusProvider } from "./components/HttpStatusProvider";
19
+
20
+ // Hooks
21
+ export { useDeferred } from "./hooks/useDeferred";
22
+
23
+ // Utilities
24
+ export { createHttpStatusSink } from "./utils/createHttpStatusSink";
25
+
26
+ // Types
27
+ export type { ClientOnlyProps } from "./components/ClientOnly";
28
+
29
+ export type { ServerOnlyProps } from "./components/ServerOnly";
30
+
31
+ export type { AwaitProps } from "./components/Await";
32
+
33
+ export type { StreamedProps } from "./components/Streamed";
34
+
35
+ export type { HttpStatusCodeProps } from "./components/HttpStatusCode";
36
+
37
+ export type { HttpStatusProviderProps } from "./components/HttpStatusProvider";
38
+
39
+ export type { HttpStatusSink } from "./utils/createHttpStatusSink";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Render-scoped HTTP status sink. Created per request on the server, passed to
3
+ * `<HttpStatusProvider sink={...}>`, and read after `renderToString` /
4
+ * `renderToStream` to apply the value to the HTTP response.
5
+ *
6
+ * Last write wins: if the rendered tree mounts more than one
7
+ * `<HttpStatusCode />`, the value reflects the last component that ran during
8
+ * the render pass.
9
+ *
10
+ * No-op on the client — `<HttpStatusCode />` reads the optional context and
11
+ * skips the write when no provider is mounted, so the same component tree can
12
+ * be hydrated without changing behaviour.
13
+ *
14
+ * Constraints:
15
+ * - **Per-request only.** Don't share a sink across requests; the rendered
16
+ * tree mutates `code` in place. Module-level singletons leak status
17
+ * between concurrent requests.
18
+ * - **Don't `Object.freeze` the sink.** The component writes to `.code`;
19
+ * freezing makes the assignment throw under ESM strict mode.
20
+ * - **Hydration symmetry:** mount `<HttpStatusProvider>` on both server and
21
+ * client (with a throwaway client sink). Solid emits `data-hk` markers
22
+ * per component boundary; an extra provider on one side desyncs the
23
+ * counter and breaks the hydration walker.
24
+ */
25
+ export interface HttpStatusSink {
26
+ code: number | undefined;
27
+ }
28
+
29
+ export function createHttpStatusSink(): HttpStatusSink {
30
+ return { code: undefined };
31
+ }
@@ -0,0 +1,26 @@
1
+ import { createSignal, onMount } from "solid-js";
2
+
3
+ import type { Accessor } from "solid-js";
4
+
5
+ /**
6
+ * Returns a boolean accessor that is `false` during initial render (SSR
7
+ * and the first client paint) and flips to `true` once the component
8
+ * has mounted in the browser.
9
+ *
10
+ * Solid guarantees that `onMount` does NOT fire during `renderToString` /
11
+ * `renderToStream`, so the accessor stays `false` server-side — this is
12
+ * the building block for SSR boundary components (`<ClientOnly>` /
13
+ * `<ServerOnly>`).
14
+ *
15
+ * Consolidates the identical `createSignal(false) + onMount(setMounted)`
16
+ * pattern across the two boundary components (§8a Q15).
17
+ */
18
+ export function createMountedSignal(): Accessor<boolean> {
19
+ const [mounted, setMounted] = createSignal(false);
20
+
21
+ onMount(() => {
22
+ setMounted(true);
23
+ });
24
+
25
+ return mounted;
26
+ }