@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.
- package/README.md +169 -14
- package/dist/cjs/index.d.ts +20 -1
- package/dist/cjs/index.js +0 -0
- package/dist/cjs/ssr.d.ts +163 -0
- package/dist/cjs/ssr.js +263 -0
- package/dist/esm/index.d.mts +20 -1
- package/dist/esm/index.mjs +0 -0
- package/dist/esm/ssr.d.mts +163 -0
- package/dist/esm/ssr.mjs +254 -0
- package/dist/types/RouterProvider.d.ts.map +1 -1
- package/dist/types/components/Await.d.ts +30 -0
- package/dist/types/components/Await.d.ts.map +1 -0
- package/dist/types/components/ClientOnly.d.ts +7 -0
- package/dist/types/components/ClientOnly.d.ts.map +1 -0
- package/dist/types/components/HttpStatusCode.d.ts +52 -0
- package/dist/types/components/HttpStatusCode.d.ts.map +1 -0
- package/dist/types/components/HttpStatusProvider.d.ts +9 -0
- package/dist/types/components/HttpStatusProvider.d.ts.map +1 -0
- package/dist/types/components/Link.d.ts.map +1 -1
- package/dist/types/components/RouteView/RouteView.d.ts.map +1 -1
- package/dist/types/components/RouteView/components.d.ts.map +1 -1
- package/dist/types/components/RouteView/helpers.d.ts.map +1 -1
- package/dist/types/components/ServerOnly.d.ts +7 -0
- package/dist/types/components/ServerOnly.d.ts.map +1 -0
- package/dist/types/components/Streamed.d.ts +18 -0
- package/dist/types/components/Streamed.d.ts.map +1 -0
- package/dist/types/constants.d.ts +20 -2
- package/dist/types/constants.d.ts.map +1 -1
- package/dist/types/context.d.ts +9 -0
- package/dist/types/context.d.ts.map +1 -1
- package/dist/types/createSignalFromSource.d.ts.map +1 -1
- package/dist/types/createStoreFromSource.d.ts +19 -0
- package/dist/types/createStoreFromSource.d.ts.map +1 -1
- package/dist/types/directives/link.d.ts.map +1 -1
- package/dist/types/dom-utils/__test-helpers/expected-fragment.d.ts +31 -0
- package/dist/types/dom-utils/__test-helpers/expected-fragment.d.ts.map +1 -0
- package/dist/types/dom-utils/__test-helpers/index.d.ts +9 -0
- package/dist/types/dom-utils/__test-helpers/index.d.ts.map +1 -0
- package/dist/types/dom-utils/link-utils.d.ts +23 -0
- package/dist/types/dom-utils/link-utils.d.ts.map +1 -1
- package/dist/types/dom-utils/route-announcer.d.ts.map +1 -1
- package/dist/types/dom-utils/scroll-restore.d.ts +38 -1
- package/dist/types/dom-utils/scroll-restore.d.ts.map +1 -1
- package/dist/types/hooks/useDeferred.d.ts +16 -0
- package/dist/types/hooks/useDeferred.d.ts.map +1 -0
- package/dist/types/hooks/useNavigator.d.ts.map +1 -1
- package/dist/types/hooks/useRouteUtils.d.ts.map +1 -1
- package/dist/types/hooks/useRouter.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/ssr.d.ts +16 -0
- package/dist/types/ssr.d.ts.map +1 -0
- package/dist/types/utils/createHttpStatusSink.d.ts +29 -0
- package/dist/types/utils/createHttpStatusSink.d.ts.map +1 -0
- package/dist/types/utils/createMountedSignal.d.ts +16 -0
- package/dist/types/utils/createMountedSignal.d.ts.map +1 -0
- package/package.json +20 -4
- package/src/RouterProvider.tsx +36 -30
- package/src/components/Await.tsx +56 -0
- package/src/components/ClientOnly.tsx +20 -0
- package/src/components/HttpStatusCode.tsx +65 -0
- package/src/components/HttpStatusProvider.tsx +21 -0
- package/src/components/Link.tsx +21 -15
- package/src/components/RouteView/RouteView.tsx +19 -18
- package/src/components/RouteView/components.tsx +34 -28
- package/src/components/RouteView/helpers.tsx +0 -0
- package/src/components/ServerOnly.tsx +20 -0
- package/src/components/Streamed.tsx +23 -0
- package/src/constants.ts +20 -2
- package/src/context.ts +21 -1
- package/src/createSignalFromSource.ts +48 -5
- package/src/createStoreFromSource.ts +49 -2
- package/src/directives/link.tsx +41 -16
- package/src/hooks/useDeferred.tsx +36 -0
- package/src/hooks/useNavigator.tsx +3 -12
- package/src/hooks/useRouteUtils.tsx +39 -1
- package/src/hooks/useRouter.tsx +3 -12
- package/src/index.tsx +2 -0
- package/src/ssr.tsx +39 -0
- package/src/utils/createHttpStatusSink.ts +31 -0
- 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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
$$type: SELF_MARKER,
|
|
74
|
+
return createMarker<SelfMarker>(SELF_MARKER, () => props.children, {
|
|
55
75
|
fallback: props.fallback,
|
|
56
|
-
|
|
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
|
-
|
|
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";
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
|
19
|
-
// notified for that internal update, so we must sync
|
|
20
|
-
// No-op when snapshot is unchanged (signal equality check).
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/directives/link.tsx
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
41
|
+
anchor.removeAttribute("href");
|
|
37
42
|
} else {
|
|
38
|
-
|
|
43
|
+
anchor.href = href;
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
applyLinkA11y(element);
|
|
43
48
|
|
|
44
|
-
// Active class tracking (
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
49
|
+
return utils;
|
|
12
50
|
};
|
package/src/hooks/useRouter.tsx
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
+
}
|