@real-router/preact 0.11.1 → 0.13.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 (44) hide show
  1. package/README.md +140 -14
  2. package/dist/cjs/index.d.ts +21 -6
  3. package/dist/cjs/index.d.ts.map +1 -1
  4. package/dist/cjs/index.js +1 -1
  5. package/dist/cjs/index.js.map +1 -1
  6. package/dist/cjs/ssr.d.ts +169 -0
  7. package/dist/cjs/ssr.d.ts.map +1 -0
  8. package/dist/cjs/ssr.js +2 -0
  9. package/dist/cjs/ssr.js.map +1 -0
  10. package/dist/cjs/useRoute-B3rj5MXo.js +2 -0
  11. package/dist/cjs/useRoute-B3rj5MXo.js.map +1 -0
  12. package/dist/esm/index.d.mts +21 -6
  13. package/dist/esm/index.d.mts.map +1 -1
  14. package/dist/esm/index.mjs +1 -1
  15. package/dist/esm/index.mjs.map +1 -1
  16. package/dist/esm/ssr.d.mts +169 -0
  17. package/dist/esm/ssr.d.mts.map +1 -0
  18. package/dist/esm/ssr.mjs +2 -0
  19. package/dist/esm/ssr.mjs.map +1 -0
  20. package/dist/esm/useRoute-BSPVVbLz.mjs +2 -0
  21. package/dist/esm/useRoute-BSPVVbLz.mjs.map +1 -0
  22. package/package.json +23 -6
  23. package/src/RouterProvider.tsx +15 -2
  24. package/src/components/Await.tsx +99 -0
  25. package/src/components/ClientOnly.tsx +25 -0
  26. package/src/components/HttpStatusCode.tsx +82 -0
  27. package/src/components/HttpStatusProvider.tsx +22 -0
  28. package/src/components/Link.tsx +52 -39
  29. package/src/components/RouteView/RouteView.tsx +12 -8
  30. package/src/components/RouteView/helpers.tsx +20 -19
  31. package/src/components/RouterErrorBoundary.tsx +28 -3
  32. package/src/components/ServerOnly.tsx +26 -0
  33. package/src/components/Streamed.tsx +24 -0
  34. package/src/context.ts +17 -0
  35. package/src/hooks/useDeferred.tsx +26 -0
  36. package/src/hooks/useIsActiveRoute.tsx +21 -13
  37. package/src/hooks/useNavigator.tsx +5 -12
  38. package/src/hooks/useRoute.tsx +7 -8
  39. package/src/hooks/useRouteNode.tsx +11 -7
  40. package/src/hooks/useRouter.tsx +5 -12
  41. package/src/ssr.ts +39 -0
  42. package/src/types.ts +2 -2
  43. package/src/useSyncExternalStore.ts +20 -0
  44. package/src/utils/createHttpStatusSink.ts +27 -0
@@ -1,5 +1,4 @@
1
1
  import { memo } from "preact/compat";
2
- import { useCallback, useMemo } from "preact/hooks";
3
2
 
4
3
  import { EMPTY_PARAMS, EMPTY_OPTIONS } from "../constants";
5
4
  import {
@@ -13,8 +12,26 @@ import { useIsActiveRoute } from "../hooks/useIsActiveRoute";
13
12
  import { useRouter } from "../hooks/useRouter";
14
13
 
15
14
  import type { LinkProps } from "../types";
16
- import type { FunctionComponent, JSX } from "preact";
15
+ import type { FunctionComponent, TargetedMouseEvent } from "preact";
17
16
 
17
+ /**
18
+ * Custom comparator for `Link`'s `memo()` wrapper.
19
+ *
20
+ * **Maintenance contract:** every field in `LinkProps` MUST appear in either
21
+ * the `===` chain (primitives + identity-checked references like `onClick` /
22
+ * `children` / `style`) or in the `shallowEqual` arms (object-valued
23
+ * `routeParams` / `routeOptions`). When adding a new prop to `LinkProps`,
24
+ * extend this function in the same PR — `tests/functional/Link.test.tsx`
25
+ * contains a regression-guard that fails the build if `LinkProps` gains a
26
+ * field that is not compared here.
27
+ *
28
+ * **Intentional omissions:** `props` (the rest-spread of HTMLAnchorElement
29
+ * attributes — `aria-label`, `data-*`, `target`-other-than-`_blank`-handling,
30
+ * etc.) is NOT compared. A change to `aria-label` will NOT trigger a Link
31
+ * re-render. This is by design: dynamic `aria-label` is rare; consumers who
32
+ * truly need a reactive aria-label should call `<Link key={ariaLabel}>` to
33
+ * force a remount.
34
+ */
18
35
  function areLinkPropsEqual(
19
36
  prev: Readonly<LinkProps>,
20
37
  next: Readonly<LinkProps>,
@@ -57,10 +74,7 @@ export const Link: FunctionComponent<LinkProps> = memo(
57
74
  // change caught by shallowEqual), so they're safe to use directly in hook
58
75
  // deps without useStableValue.
59
76
 
60
- // Hash-aware active (#532): when `hash` prop is set, isActive requires
61
- // both route AND hash to match. Tab-style UI (multiple links sharing
62
- // routeName but differing in hash) needs this to avoid marking all tabs
63
- // active by route-name alone.
77
+ // Hash-aware active (#532) see useIsActiveRoute for the contract.
64
78
  const isActive = useIsActiveRoute(
65
79
  routeName,
66
80
  routeParams,
@@ -69,46 +83,45 @@ export const Link: FunctionComponent<LinkProps> = memo(
69
83
  hash,
70
84
  );
71
85
 
72
- const href = useMemo(
73
- () =>
74
- buildHref(
75
- router,
76
- routeName,
77
- routeParams,
78
- hash === undefined ? undefined : { hash },
79
- ),
80
- [router, routeName, routeParams, hash],
86
+ // `buildHref` is a cheap synchronous call (route-tree lookup + string
87
+ // concat). Wrapping it in `useMemo` allocates a deps array on every
88
+ // render that does not bail out — and on bail-out the function body
89
+ // doesn't execute, so the cache never pays off. Same logic for
90
+ // `buildActiveClassName` and `handleClick` below.
91
+ const href = buildHref(
92
+ router,
93
+ routeName,
94
+ routeParams,
95
+ hash === undefined ? undefined : { hash },
81
96
  );
82
97
 
83
- const handleClick = useCallback(
84
- (evt: JSX.TargetedMouseEvent<HTMLAnchorElement>) => {
85
- if (onClick) {
86
- onClick(evt);
87
-
88
- if (evt.defaultPrevented) {
89
- return;
90
- }
91
- }
98
+ const handleClick = (evt: TargetedMouseEvent<HTMLAnchorElement>): void => {
99
+ if (onClick) {
100
+ onClick(evt);
92
101
 
93
- if (!shouldNavigate(evt) || target === "_blank") {
102
+ if (evt.defaultPrevented) {
94
103
  return;
95
104
  }
105
+ }
96
106
 
97
- evt.preventDefault();
98
- navigateWithHash(
99
- router,
100
- routeName,
101
- routeParams,
102
- hash,
103
- routeOptions,
104
- ).catch(() => {});
105
- },
106
- [onClick, target, router, routeName, routeParams, routeOptions, hash],
107
- );
107
+ if (!shouldNavigate(evt) || target === "_blank") {
108
+ return;
109
+ }
110
+
111
+ evt.preventDefault();
112
+ navigateWithHash(
113
+ router,
114
+ routeName,
115
+ routeParams,
116
+ hash,
117
+ routeOptions,
118
+ ).catch(() => {});
119
+ };
108
120
 
109
- const finalClassName = useMemo(
110
- () => buildActiveClassName(isActive, activeClassName, className),
111
- [isActive, activeClassName, className],
121
+ const finalClassName = buildActiveClassName(
122
+ isActive,
123
+ activeClassName,
124
+ className,
112
125
  );
113
126
 
114
127
  return (
@@ -24,17 +24,21 @@ function RouteViewRoot({
24
24
  return collected;
25
25
  }, [children]);
26
26
 
27
- if (!route) {
28
- return null;
29
- }
27
+ const routeName = route?.name;
30
28
 
31
- const { rendered } = buildRenderList(elements, route.name, nodeName);
29
+ // buildRenderList is O(N) over Match/Self/NotFound children. Memo on
30
+ // (elements, routeName, nodeName) skips the re-walk on parent re-renders
31
+ // that don't change the active route; navigations always invalidate via
32
+ // routeName.
33
+ const rendered = useMemo(() => {
34
+ if (routeName === undefined) {
35
+ return [];
36
+ }
32
37
 
33
- if (rendered.length > 0) {
34
- return <>{rendered}</>;
35
- }
38
+ return buildRenderList(elements, routeName, nodeName).rendered;
39
+ }, [elements, routeName, nodeName]);
36
40
 
37
- return null;
41
+ return rendered.length > 0 ? <>{rendered}</> : null;
38
42
  }
39
43
 
40
44
  RouteViewRoot.displayName = "RouteView";
@@ -70,24 +70,22 @@ function renderSlot(
70
70
  return <Fragment key={key}>{content}</Fragment>;
71
71
  }
72
72
 
73
- function recordFallback(child: VNode, slots: FallbackSlots): boolean {
73
+ function isFallbackKind(child: VNode): boolean {
74
+ return child.type === NotFound || child.type === Self;
75
+ }
76
+
77
+ function assignFallbackSlot(child: VNode, slots: FallbackSlots): void {
74
78
  if (child.type === NotFound) {
75
79
  slots.notFoundChildren = (child.props as NotFoundProps).children;
76
80
 
77
- return true;
81
+ return;
78
82
  }
79
83
 
80
- if (child.type === Self) {
81
- if (!slots.selfFound) {
82
- slots.selfChildren = (child.props as SelfProps).children;
83
- slots.selfFallback = (child.props as SelfProps).fallback;
84
- slots.selfFound = true;
85
- }
86
-
87
- return true;
84
+ if (!slots.selfFound) {
85
+ slots.selfChildren = (child.props as SelfProps).children;
86
+ slots.selfFallback = (child.props as SelfProps).fallback;
87
+ slots.selfFound = true;
88
88
  }
89
-
90
- return false;
91
89
  }
92
90
 
93
91
  function processMatch(
@@ -96,7 +94,12 @@ function processMatch(
96
94
  nodeName: string,
97
95
  alreadyActive: boolean,
98
96
  ): VNode | null {
99
- const { segment, exact = false, fallback } = child.props as MatchProps;
97
+ const {
98
+ segment,
99
+ exact = false,
100
+ fallback,
101
+ children,
102
+ } = child.props as MatchProps;
100
103
  const fullSegmentName = nodeName ? `${nodeName}.${segment}` : segment;
101
104
  const isActive =
102
105
  !alreadyActive && isSegmentMatch(routeName, fullSegmentName, exact);
@@ -105,11 +108,7 @@ function processMatch(
105
108
  return null;
106
109
  }
107
110
 
108
- return renderSlot(
109
- (child.props as MatchProps).children,
110
- fullSegmentName,
111
- fallback,
112
- );
111
+ return renderSlot(children, fullSegmentName, fallback);
113
112
  }
114
113
 
115
114
  function appendFallback(
@@ -150,7 +149,9 @@ export function buildRenderList(
150
149
  const rendered: VNode[] = [];
151
150
 
152
151
  for (const child of elements) {
153
- if (recordFallback(child, slots)) {
152
+ if (isFallbackKind(child)) {
153
+ assignFallbackSlot(child, slots);
154
+
154
155
  continue;
155
156
  }
156
157
 
@@ -1,6 +1,6 @@
1
1
  import { createDismissableError } from "@real-router/sources";
2
2
  import { Fragment } from "preact";
3
- import { useEffect, useRef } from "preact/hooks";
3
+ import { useEffect, useLayoutEffect, useRef } from "preact/hooks";
4
4
 
5
5
  import { useRouter } from "../hooks/useRouter";
6
6
  import { useSyncExternalStore } from "../useSyncExternalStore";
@@ -21,12 +21,32 @@ export interface RouterErrorBoundaryProps {
21
21
  ) => void;
22
22
  }
23
23
 
24
+ /**
25
+ * Declarative navigation-error boundary.
26
+ *
27
+ * **Not** a Preact `componentDidCatch`-style ErrorBoundary — this component
28
+ * does NOT catch render-time exceptions from `children`. It is a compositional
29
+ * component that subscribes to `createDismissableError` from
30
+ * `@real-router/sources` and renders `fallback(error, resetError)` ALONGSIDE
31
+ * `children` (wrapped in a `<Fragment>`) when the router emits a navigation
32
+ * error (guard rejection, ROUTE_NOT_FOUND, etc.). The boundary auto-resets on
33
+ * the next successful navigation; `resetError()` lets the consumer dismiss
34
+ * the fallback imperatively.
35
+ *
36
+ * For real exception boundaries, wrap children in a Preact ErrorBoundary
37
+ * (e.g. `preact-iso/ErrorBoundary` or a custom `componentDidCatch` class) —
38
+ * the two can coexist.
39
+ */
24
40
  export function RouterErrorBoundary({
25
41
  children,
26
42
  fallback,
27
43
  onError,
28
44
  }: RouterErrorBoundaryProps): VNode {
29
45
  const router = useRouter();
46
+
47
+ // `createDismissableError` is the cached factory from `@real-router/sources`
48
+ // — keyed per-router, identity stable across renders. `useMemo` would wrap
49
+ // a call that already memoizes downstream.
30
50
  const store = createDismissableError(router);
31
51
  const snapshot = useSyncExternalStore(
32
52
  store.subscribe,
@@ -36,9 +56,14 @@ export function RouterErrorBoundary({
36
56
 
37
57
  const onErrorRef = useRef(onError);
38
58
 
39
- // eslint-disable-next-line @eslint-react/refs -- "latest ref" pattern: sync callback to ref to avoid effect re-runs
40
- onErrorRef.current = onError;
59
+ useLayoutEffect(() => {
60
+ onErrorRef.current = onError;
61
+ });
41
62
 
63
+ // snapshot.version is the @real-router/sources dismissable-error invariant:
64
+ // it is the only field that monotonically advances on each new error episode
65
+ // (snapshot.error/toRoute/fromRoute are correlated reads within the same
66
+ // version frame), so depending on it covers all error fields by construction.
42
67
  useEffect(() => {
43
68
  if (snapshot.error) {
44
69
  onErrorRef.current?.(
@@ -0,0 +1,26 @@
1
+ import { useEffect, useState } from "preact/hooks";
2
+
3
+ import type { ComponentChildren } from "preact";
4
+
5
+ export interface ServerOnlyProps {
6
+ readonly children: ComponentChildren;
7
+ readonly fallback?: ComponentChildren;
8
+ }
9
+
10
+ export function ServerOnly({
11
+ children,
12
+ fallback = null,
13
+ }: ServerOnlyProps): ComponentChildren {
14
+ const [mounted, setMounted] = useState(false);
15
+
16
+ useEffect(() => {
17
+ // SSR/hydration boundary: server emits the children branch, client matches
18
+ // it on first paint, then this effect flips state to swap in the fallback
19
+ // (or hide entirely). The intentional re-render keeps markup consistent
20
+ // across renders.
21
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- intentional post-hydration swap
22
+ setMounted(true);
23
+ }, []);
24
+
25
+ return mounted ? fallback : children;
26
+ }
@@ -0,0 +1,24 @@
1
+ import { Suspense } from "preact/compat";
2
+
3
+ import type { ComponentChildren } from "preact";
4
+
5
+ export interface StreamedProps {
6
+ /** Shown while any descendant `<Await>` / `use(promise)`-equivalent suspends. */
7
+ readonly fallback: ComponentChildren;
8
+ readonly children: ComponentChildren;
9
+ }
10
+
11
+ /**
12
+ * Cross-adapter alias for `<Suspense fallback={…}>` from `preact/compat`.
13
+ * Pairs with `<Await>` for symmetry with the React/Solid/Svelte/Vue/Angular
14
+ * SSR streaming naming.
15
+ *
16
+ * Preact's `Suspense` is part of `preact/compat` (experimental). For
17
+ * production streaming the preact-render-to-string toolchain is required.
18
+ */
19
+ export function Streamed({
20
+ fallback,
21
+ children,
22
+ }: StreamedProps): ComponentChildren {
23
+ return <Suspense fallback={fallback}>{children}</Suspense>;
24
+ }
package/src/context.ts CHANGED
@@ -1,10 +1,27 @@
1
1
  import { createContext } from "preact";
2
+ import { useContext } from "preact/hooks";
2
3
 
3
4
  import type { RouteContext as RouteContextType } from "./types";
4
5
  import type { Router, Navigator } from "@real-router/core";
6
+ import type { Context } from "preact";
5
7
 
6
8
  export const RouteContext = createContext<RouteContextType | null>(null);
7
9
 
8
10
  export const RouterContext = createContext<Router | null>(null);
9
11
 
10
12
  export const NavigatorContext = createContext<Navigator | null>(null);
13
+
14
+ export function createUseContextOrThrow<T>(
15
+ context: Context<T | null>,
16
+ hookName: string,
17
+ ): () => T {
18
+ return () => {
19
+ const value = useContext(context);
20
+
21
+ if (!value) {
22
+ throw new Error(`${hookName} must be used within a RouterProvider`);
23
+ }
24
+
25
+ return value;
26
+ };
27
+ }
@@ -0,0 +1,26 @@
1
+ import { useRoute } from "./useRoute";
2
+
3
+ interface DeferredContext {
4
+ ssrDataDeferred?: Record<string, Promise<unknown>>;
5
+ }
6
+
7
+ const NEVER_PROMISE = new Promise<never>(() => {
8
+ // Intentionally never resolves — surfaces a forever-pending Suspense boundary
9
+ // when a key is requested that the loader never declared.
10
+ });
11
+
12
+ /**
13
+ * Read a deferred promise published by `defer({ deferred: { <key>: Promise } })`
14
+ * inside an SSR data loader. Mirror of `@real-router/react/ssr` `useDeferred`
15
+ * — same `state.context.ssrDataDeferred` contract, same NEVER-on-missing
16
+ * fallback. Pair with `<Await>` (this package) which adds Preact-side
17
+ * promise-status tracking since Preact 10 has no `use(promise)` analogue.
18
+ */
19
+ export function useDeferred<T = unknown>(key: string): Promise<T> {
20
+ const { route } = useRoute();
21
+ const context = route.context as DeferredContext;
22
+ const deferred = context.ssrDataDeferred;
23
+ const promise = deferred?.[key];
24
+
25
+ return (promise ?? NEVER_PROMISE) as Promise<T>;
26
+ }
@@ -1,9 +1,11 @@
1
1
  import { createActiveRouteSource } from "@real-router/sources";
2
+ import { useMemo } from "preact/hooks";
2
3
 
3
4
  import { useSyncExternalStore } from "../useSyncExternalStore";
4
5
  import { useRouter } from "./useRouter";
5
6
 
6
7
  import type { Params } from "@real-router/core";
8
+ import type { ActiveRouteSourceOptions } from "@real-router/sources";
7
9
 
8
10
  export function useIsActiveRoute(
9
11
  routeName: string,
@@ -15,19 +17,25 @@ export function useIsActiveRoute(
15
17
  const router = useRouter();
16
18
 
17
19
  // createActiveRouteSource is per-router + canonical-args cached in
18
- // @real-router/sources, so passing params by reference is safe. The
19
- // `hash` argument (#532) participates in the cache key a tab Link
20
- // pointing to `/settings#account` shares its source only with consumers
21
- // using the same routeName + params + hash.
22
- // exactOptionalPropertyTypes forbids `{ hash: undefined }` literally, so
23
- // we conditionally include the key only when the caller passed a value.
24
- const store = createActiveRouteSource(
25
- router,
26
- routeName,
27
- params,
28
- hash === undefined
29
- ? { strict, ignoreQueryParams }
30
- : { strict, ignoreQueryParams, hash },
20
+ // @real-router/sources. Caching the opts object + memoising the source
21
+ // lookup avoids (a) re-allocating the literal on every render and (b)
22
+ // re-running canonicalJson(params) on the cache lookup path. The `hash`
23
+ // argument (#532) participates in the cache key a tab Link pointing to
24
+ // `/settings#account` shares its source only with consumers using the
25
+ // same routeName + params + hash. exactOptionalPropertyTypes forbids
26
+ // `{ hash: undefined }` literally, so we conditionally include the key
27
+ // only when the caller passed a value.
28
+ const opts = useMemo<ActiveRouteSourceOptions>(
29
+ () =>
30
+ hash === undefined
31
+ ? { strict, ignoreQueryParams }
32
+ : { strict, ignoreQueryParams, hash },
33
+ [strict, ignoreQueryParams, hash],
34
+ );
35
+
36
+ const store = useMemo(
37
+ () => createActiveRouteSource(router, routeName, params, opts),
38
+ [router, routeName, params, opts],
31
39
  );
32
40
 
33
41
  return useSyncExternalStore(
@@ -1,15 +1,8 @@
1
- import { useContext } from "preact/hooks";
2
-
3
- import { NavigatorContext } from "../context";
1
+ import { createUseContextOrThrow, NavigatorContext } from "../context";
4
2
 
5
3
  import type { Navigator } from "@real-router/core";
6
4
 
7
- export const useNavigator = (): Navigator => {
8
- const navigator = useContext(NavigatorContext);
9
-
10
- if (!navigator) {
11
- throw new Error("useNavigator must be used within a RouterProvider");
12
- }
13
-
14
- return navigator;
15
- };
5
+ export const useNavigator: () => Navigator = createUseContextOrThrow(
6
+ NavigatorContext,
7
+ "useNavigator",
8
+ );
@@ -1,19 +1,18 @@
1
- import { useContext } from "preact/hooks";
2
-
3
- import { RouteContext } from "../context";
1
+ import { createUseContextOrThrow, RouteContext } from "../context";
4
2
 
5
3
  import type { RouteContext as RouteContextType } from "../types";
6
4
  import type { Params, State } from "@real-router/core";
7
5
 
6
+ const useRouteContextOrThrow = createUseContextOrThrow(
7
+ RouteContext,
8
+ "useRoute",
9
+ );
10
+
8
11
  export const useRoute = <P extends Params = Params>(): Omit<
9
12
  RouteContextType<P>,
10
13
  "route"
11
14
  > & { route: State<P> } => {
12
- const routeContext = useContext(RouteContext);
13
-
14
- if (!routeContext) {
15
- throw new Error("useRoute must be used within a RouterProvider");
16
- }
15
+ const routeContext = useRouteContextOrThrow();
17
16
 
18
17
  if (!routeContext.route) {
19
18
  throw new Error(
@@ -1,19 +1,20 @@
1
- import { getNavigator } from "@real-router/core";
2
1
  import { createRouteNodeSource } from "@real-router/sources";
3
2
  import { useMemo } from "preact/hooks";
4
3
 
5
4
  import { useSyncExternalStore } from "../useSyncExternalStore";
5
+ import { useNavigator } from "./useNavigator";
6
6
  import { useRouter } from "./useRouter";
7
7
 
8
8
  import type { RouteContext } from "../types";
9
9
 
10
10
  export function useRouteNode(nodeName: string): RouteContext {
11
11
  const router = useRouter();
12
+ const navigator = useNavigator();
12
13
 
13
- const store = useMemo(
14
- () => createRouteNodeSource(router, nodeName),
15
- [router, nodeName],
16
- );
14
+ // `createRouteNodeSource` is the cached factory from `@real-router/sources`
15
+ // keyed on (router, nodeName) — identical args return identical refs across
16
+ // renders. No `useMemo` needed.
17
+ const store = createRouteNodeSource(router, nodeName);
17
18
 
18
19
  const { route, previousRoute } = useSyncExternalStore(
19
20
  store.subscribe,
@@ -21,8 +22,11 @@ export function useRouteNode(nodeName: string): RouteContext {
21
22
  store.getSnapshot,
22
23
  );
23
24
 
24
- const navigator = useMemo(() => getNavigator(router), [router]);
25
-
25
+ // Public stable-ref contract (locked by `useRouteNode.test.tsx` "should
26
+ // return stable reference when nothing changes"): consecutive renders with
27
+ // identical (navigator, route, previousRoute) return the same RouteContext
28
+ // ref. Drop this `useMemo` and downstream consumers re-render on every
29
+ // parent re-render.
26
30
  return useMemo(
27
31
  (): RouteContext => ({ navigator, route, previousRoute }),
28
32
  [navigator, route, previousRoute],
@@ -1,15 +1,8 @@
1
- import { useContext } from "preact/hooks";
2
-
3
- import { RouterContext } from "../context";
1
+ import { createUseContextOrThrow, RouterContext } from "../context";
4
2
 
5
3
  import type { Router } from "@real-router/core";
6
4
 
7
- export const useRouter = (): Router => {
8
- const router = useContext(RouterContext);
9
-
10
- if (!router) {
11
- throw new Error("useRouter must be used within a RouterProvider");
12
- }
13
-
14
- return router;
15
- };
5
+ export const useRouter: () => Router = createUseContextOrThrow(
6
+ RouterContext,
7
+ "useRouter",
8
+ );
package/src/ssr.ts ADDED
@@ -0,0 +1,39 @@
1
+ // SSR-feature entry — Preact 10+
2
+ //
3
+ // Server-side and SSR-aware components/hooks. Mirror of `@real-router/react/ssr`
4
+ // — same exports, same API surface. Pair with `@real-router/ssr-data-plugin`'s
5
+ // `defer()` helper and `injectDeferredScripts` server-side wire-format.
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";
package/src/types.ts CHANGED
@@ -4,7 +4,7 @@ import type {
4
4
  Navigator,
5
5
  State,
6
6
  } from "@real-router/core";
7
- import type { JSX } from "preact";
7
+ import type { HTMLAttributes } from "preact";
8
8
 
9
9
  export interface RouteState<P extends Params = Params> {
10
10
  route: State<P> | undefined;
@@ -16,7 +16,7 @@ export type RouteContext<P extends Params = Params> = {
16
16
  } & RouteState<P>;
17
17
 
18
18
  export interface LinkProps<P extends Params = Params> extends Omit<
19
- JSX.HTMLAttributes<HTMLAnchorElement>,
19
+ HTMLAttributes<HTMLAnchorElement>,
20
20
  "className"
21
21
  > {
22
22
  routeName: string;
@@ -14,6 +14,26 @@ import { useEffect, useState } from "preact/hooks";
14
14
  *
15
15
  * The updater uses `Object.is` to bail out when the snapshot
16
16
  * is referentially stable, preventing redundant re-renders.
17
+ *
18
+ * SSR semantics: `_getServerSnapshot` is intentionally ignored.
19
+ * Preact's `preact-render-to-string` runs `useState(getSnapshot)`
20
+ * on the server and does not commit effects, so the initial
21
+ * render already uses `getSnapshot()`. Real-Router's `createRouteSource`
22
+ * (and friends) return the same value on server and client given the
23
+ * same `router` instance, so passing `getSnapshot` itself as the third
24
+ * argument at every call site is the symmetric SSR contract; a separate
25
+ * `getServerSnapshot` would diverge during hydration. Consumers that
26
+ * truly need a different server value should branch in `getSnapshot`.
27
+ *
28
+ * Stable-reference contract: `subscribe` and `getSnapshot` are deps of
29
+ * the subscription effect. If a consumer passes inline closures, every
30
+ * render triggers `unsubscribe → subscribe` plus a fresh `sync()` pass
31
+ * — a silent O(N) reconnect that the Preact polyfill cannot bail out
32
+ * of (React's native impl uses an internal sub-store keyed by identity;
33
+ * we cannot replicate that without losing the latest-snapshot guarantee).
34
+ * All Real-Router hooks pass router-keyed cached factories from
35
+ * `@real-router/sources`, which produce stable refs per `(router, args…)`
36
+ * — keep that pattern for every external use.
17
37
  */
18
38
  export function useSyncExternalStore<T>(
19
39
  subscribe: (onStoreChange: () => void) => () => void,