@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.
- package/README.md +140 -14
- package/dist/cjs/index.d.ts +21 -6
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/ssr.d.ts +169 -0
- package/dist/cjs/ssr.d.ts.map +1 -0
- package/dist/cjs/ssr.js +2 -0
- package/dist/cjs/ssr.js.map +1 -0
- package/dist/cjs/useRoute-B3rj5MXo.js +2 -0
- package/dist/cjs/useRoute-B3rj5MXo.js.map +1 -0
- package/dist/esm/index.d.mts +21 -6
- package/dist/esm/index.d.mts.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/ssr.d.mts +169 -0
- package/dist/esm/ssr.d.mts.map +1 -0
- package/dist/esm/ssr.mjs +2 -0
- package/dist/esm/ssr.mjs.map +1 -0
- package/dist/esm/useRoute-BSPVVbLz.mjs +2 -0
- package/dist/esm/useRoute-BSPVVbLz.mjs.map +1 -0
- package/package.json +23 -6
- package/src/RouterProvider.tsx +15 -2
- package/src/components/Await.tsx +99 -0
- package/src/components/ClientOnly.tsx +25 -0
- package/src/components/HttpStatusCode.tsx +82 -0
- package/src/components/HttpStatusProvider.tsx +22 -0
- package/src/components/Link.tsx +52 -39
- package/src/components/RouteView/RouteView.tsx +12 -8
- package/src/components/RouteView/helpers.tsx +20 -19
- package/src/components/RouterErrorBoundary.tsx +28 -3
- package/src/components/ServerOnly.tsx +26 -0
- package/src/components/Streamed.tsx +24 -0
- package/src/context.ts +17 -0
- package/src/hooks/useDeferred.tsx +26 -0
- package/src/hooks/useIsActiveRoute.tsx +21 -13
- package/src/hooks/useNavigator.tsx +5 -12
- package/src/hooks/useRoute.tsx +7 -8
- package/src/hooks/useRouteNode.tsx +11 -7
- package/src/hooks/useRouter.tsx +5 -12
- package/src/ssr.ts +39 -0
- package/src/types.ts +2 -2
- package/src/useSyncExternalStore.ts +20 -0
- package/src/utils/createHttpStatusSink.ts +27 -0
package/src/components/Link.tsx
CHANGED
|
@@ -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,
|
|
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)
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 =
|
|
84
|
-
(
|
|
85
|
-
|
|
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 (
|
|
102
|
+
if (evt.defaultPrevented) {
|
|
94
103
|
return;
|
|
95
104
|
}
|
|
105
|
+
}
|
|
96
106
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 =
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
27
|
+
const routeName = route?.name;
|
|
30
28
|
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
81
|
+
return;
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 {
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
);
|
package/src/hooks/useRoute.tsx
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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],
|
package/src/hooks/useRouter.tsx
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|