@real-router/react 0.14.8 → 0.14.9
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/package.json +8 -9
- package/src/RouterProvider.tsx +58 -0
- package/src/components/Link.tsx +101 -0
- package/src/components/RouterErrorBoundary.tsx +55 -0
- package/src/components/modern/RouteView/RouteView.tsx +48 -0
- package/src/components/modern/RouteView/components.tsx +13 -0
- package/src/components/modern/RouteView/helpers.tsx +130 -0
- package/src/components/modern/RouteView/index.ts +7 -0
- package/src/components/modern/RouteView/types.ts +26 -0
- package/src/constants.ts +11 -0
- package/src/context.ts +12 -0
- package/src/hooks/useIsActiveRoute.tsx +36 -0
- package/src/hooks/useNavigator.tsx +17 -0
- package/src/hooks/useRoute.tsx +17 -0
- package/src/hooks/useRouteNode.tsx +29 -0
- package/src/hooks/useRouteUtils.tsx +36 -0
- package/src/hooks/useRouter.tsx +17 -0
- package/src/hooks/useRouterError.tsx +29 -0
- package/src/hooks/useRouterTransition.tsx +18 -0
- package/src/hooks/useStableValue.tsx +29 -0
- package/src/index.ts +39 -0
- package/src/legacy.ts +31 -0
- package/src/types.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@real-router/react",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.9",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"description": "React integration for Real-Router",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
},
|
|
16
16
|
"exports": {
|
|
17
17
|
".": {
|
|
18
|
-
"development": "./src/index.ts",
|
|
19
18
|
"types": {
|
|
20
19
|
"import": "./dist/esm/index.d.mts",
|
|
21
20
|
"require": "./dist/cjs/index.d.ts"
|
|
@@ -24,7 +23,6 @@
|
|
|
24
23
|
"require": "./dist/cjs/index.js"
|
|
25
24
|
},
|
|
26
25
|
"./legacy": {
|
|
27
|
-
"development": "./src/legacy.ts",
|
|
28
26
|
"types": {
|
|
29
27
|
"import": "./dist/esm/legacy.d.mts",
|
|
30
28
|
"require": "./dist/cjs/legacy.d.ts"
|
|
@@ -34,7 +32,8 @@
|
|
|
34
32
|
}
|
|
35
33
|
},
|
|
36
34
|
"files": [
|
|
37
|
-
"dist"
|
|
35
|
+
"dist",
|
|
36
|
+
"src"
|
|
38
37
|
],
|
|
39
38
|
"homepage": "https://github.com/greydragon888/real-router",
|
|
40
39
|
"repository": {
|
|
@@ -63,9 +62,9 @@
|
|
|
63
62
|
"license": "MIT",
|
|
64
63
|
"sideEffects": false,
|
|
65
64
|
"dependencies": {
|
|
66
|
-
"@real-router/core": "^0.45.
|
|
67
|
-
"@real-router/route-utils": "^0.1.
|
|
68
|
-
"@real-router/sources": "^0.4.
|
|
65
|
+
"@real-router/core": "^0.45.2",
|
|
66
|
+
"@real-router/route-utils": "^0.1.13",
|
|
67
|
+
"@real-router/sources": "^0.4.3"
|
|
69
68
|
},
|
|
70
69
|
"devDependencies": {
|
|
71
70
|
"@testing-library/dom": "10.4.1",
|
|
@@ -73,7 +72,7 @@
|
|
|
73
72
|
"@testing-library/react": "16.3.2",
|
|
74
73
|
"@testing-library/user-event": "14.6.1",
|
|
75
74
|
"vitest-react-profiler": "1.12.0",
|
|
76
|
-
"@real-router/browser-plugin": "^0.11.
|
|
75
|
+
"@real-router/browser-plugin": "^0.11.6",
|
|
77
76
|
"dom-utils": "^0.2.7"
|
|
78
77
|
},
|
|
79
78
|
"peerDependencies": {
|
|
@@ -88,7 +87,7 @@
|
|
|
88
87
|
"test:stress": "vitest run --config vitest.config.stress.mts",
|
|
89
88
|
"type-check": "tsc --noEmit",
|
|
90
89
|
"lint": "eslint --cache --ext .ts,.tsx src/ tests/ --fix --max-warnings 0",
|
|
91
|
-
"lint:package": "
|
|
90
|
+
"lint:package": "publint",
|
|
92
91
|
"lint:types": "attw --pack .",
|
|
93
92
|
"build:dist-only": "tsdown --config-loader unrun"
|
|
94
93
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getNavigator } from "@real-router/core";
|
|
2
|
+
import { createRouteSource } from "@real-router/sources";
|
|
3
|
+
import { createRouteAnnouncer } from "dom-utils";
|
|
4
|
+
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
|
5
|
+
|
|
6
|
+
import { NavigatorContext, RouteContext, RouterContext } from "./context";
|
|
7
|
+
|
|
8
|
+
import type { Router } from "@real-router/core";
|
|
9
|
+
import type { FC, ReactNode } from "react";
|
|
10
|
+
|
|
11
|
+
export interface RouteProviderProps {
|
|
12
|
+
router: Router;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
announceNavigation?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const RouterProvider: FC<RouteProviderProps> = ({
|
|
18
|
+
router,
|
|
19
|
+
children,
|
|
20
|
+
announceNavigation,
|
|
21
|
+
}) => {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!announceNavigation) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const announcer = createRouteAnnouncer(router);
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
announcer.destroy();
|
|
31
|
+
};
|
|
32
|
+
}, [announceNavigation, router]);
|
|
33
|
+
|
|
34
|
+
const navigator = useMemo(() => getNavigator(router), [router]);
|
|
35
|
+
|
|
36
|
+
// useSyncExternalStore manages the router subscription lifecycle:
|
|
37
|
+
// subscribe connects to router on first listener, unsubscribes on last.
|
|
38
|
+
// This is Strict Mode safe — no useEffect cleanup needed.
|
|
39
|
+
const store = useMemo(() => createRouteSource(router), [router]);
|
|
40
|
+
const { route, previousRoute } = useSyncExternalStore(
|
|
41
|
+
store.subscribe,
|
|
42
|
+
store.getSnapshot,
|
|
43
|
+
store.getSnapshot, // SSR: router returns same state on server and client
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const routeContextValue = useMemo(
|
|
47
|
+
() => ({ navigator, route, previousRoute }),
|
|
48
|
+
[navigator, route, previousRoute],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<RouterContext value={router}>
|
|
53
|
+
<NavigatorContext value={navigator}>
|
|
54
|
+
<RouteContext value={routeContextValue}>{children}</RouteContext>
|
|
55
|
+
</NavigatorContext>
|
|
56
|
+
</RouterContext>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { shouldNavigate, buildHref, buildActiveClassName } from "dom-utils";
|
|
2
|
+
import { memo, useCallback, useMemo } from "react";
|
|
3
|
+
|
|
4
|
+
import { EMPTY_PARAMS, EMPTY_OPTIONS } from "../constants";
|
|
5
|
+
import { useIsActiveRoute } from "../hooks/useIsActiveRoute";
|
|
6
|
+
import { useRouter } from "../hooks/useRouter";
|
|
7
|
+
import { useStableValue } from "../hooks/useStableValue";
|
|
8
|
+
|
|
9
|
+
import type { LinkProps } from "../types";
|
|
10
|
+
import type { FC, MouseEvent } from "react";
|
|
11
|
+
|
|
12
|
+
function areLinkPropsEqual(
|
|
13
|
+
prev: Readonly<LinkProps>,
|
|
14
|
+
next: Readonly<LinkProps>,
|
|
15
|
+
): boolean {
|
|
16
|
+
return (
|
|
17
|
+
prev.routeName === next.routeName &&
|
|
18
|
+
prev.className === next.className &&
|
|
19
|
+
prev.activeClassName === next.activeClassName &&
|
|
20
|
+
prev.activeStrict === next.activeStrict &&
|
|
21
|
+
prev.ignoreQueryParams === next.ignoreQueryParams &&
|
|
22
|
+
prev.onClick === next.onClick &&
|
|
23
|
+
prev.target === next.target &&
|
|
24
|
+
prev.style === next.style &&
|
|
25
|
+
prev.children === next.children &&
|
|
26
|
+
JSON.stringify(prev.routeParams) === JSON.stringify(next.routeParams) &&
|
|
27
|
+
JSON.stringify(prev.routeOptions) === JSON.stringify(next.routeOptions)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const Link: FC<LinkProps> = memo(
|
|
32
|
+
({
|
|
33
|
+
routeName,
|
|
34
|
+
routeParams = EMPTY_PARAMS,
|
|
35
|
+
routeOptions = EMPTY_OPTIONS,
|
|
36
|
+
className,
|
|
37
|
+
activeClassName = "active",
|
|
38
|
+
activeStrict = false,
|
|
39
|
+
ignoreQueryParams = true,
|
|
40
|
+
onClick,
|
|
41
|
+
target,
|
|
42
|
+
children,
|
|
43
|
+
...props
|
|
44
|
+
}) => {
|
|
45
|
+
const router = useRouter();
|
|
46
|
+
|
|
47
|
+
const stableParams = useStableValue(routeParams);
|
|
48
|
+
const stableOptions = useStableValue(routeOptions);
|
|
49
|
+
|
|
50
|
+
const isActive = useIsActiveRoute(
|
|
51
|
+
routeName,
|
|
52
|
+
stableParams,
|
|
53
|
+
activeStrict,
|
|
54
|
+
ignoreQueryParams,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const href = useMemo(
|
|
58
|
+
() => buildHref(router, routeName, stableParams),
|
|
59
|
+
[router, routeName, stableParams],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const handleClick = useCallback(
|
|
63
|
+
(evt: MouseEvent<HTMLAnchorElement>) => {
|
|
64
|
+
if (onClick) {
|
|
65
|
+
onClick(evt);
|
|
66
|
+
|
|
67
|
+
if (evt.defaultPrevented) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!shouldNavigate(evt.nativeEvent) || target === "_blank") {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
evt.preventDefault();
|
|
77
|
+
router.navigate(routeName, stableParams, stableOptions).catch(() => {});
|
|
78
|
+
},
|
|
79
|
+
[onClick, target, router, routeName, stableParams, stableOptions],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const finalClassName = useMemo(
|
|
83
|
+
() => buildActiveClassName(isActive, activeClassName, className),
|
|
84
|
+
[isActive, activeClassName, className],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<a
|
|
89
|
+
{...props}
|
|
90
|
+
href={href}
|
|
91
|
+
className={finalClassName}
|
|
92
|
+
onClick={handleClick}
|
|
93
|
+
>
|
|
94
|
+
{children}
|
|
95
|
+
</a>
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
areLinkPropsEqual,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
Link.displayName = "Link";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { useRouterError } from "../hooks/useRouterError";
|
|
4
|
+
|
|
5
|
+
import type { RouterError, State } from "@real-router/core";
|
|
6
|
+
import type { ReactNode, JSX } from "react";
|
|
7
|
+
|
|
8
|
+
export interface RouterErrorBoundaryProps {
|
|
9
|
+
readonly children: ReactNode;
|
|
10
|
+
readonly fallback: (error: RouterError, resetError: () => void) => ReactNode;
|
|
11
|
+
readonly onError?: (
|
|
12
|
+
error: RouterError,
|
|
13
|
+
toRoute: State | null,
|
|
14
|
+
fromRoute: State | null,
|
|
15
|
+
) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function RouterErrorBoundary({
|
|
19
|
+
children,
|
|
20
|
+
fallback,
|
|
21
|
+
onError,
|
|
22
|
+
}: RouterErrorBoundaryProps): JSX.Element {
|
|
23
|
+
const snapshot = useRouterError();
|
|
24
|
+
const [dismissedVersion, setDismissedVersion] = useState(-1);
|
|
25
|
+
|
|
26
|
+
const onErrorRef = useRef(onError);
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line @eslint-react/refs -- "latest ref" pattern: sync callback to ref to avoid effect re-runs
|
|
29
|
+
onErrorRef.current = onError;
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (snapshot.error) {
|
|
33
|
+
onErrorRef.current?.(
|
|
34
|
+
snapshot.error,
|
|
35
|
+
snapshot.toRoute,
|
|
36
|
+
snapshot.fromRoute,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
// eslint-disable-next-line @eslint-react/exhaustive-deps -- onError tracked via ref, snapshot fields accessed inside callback
|
|
40
|
+
}, [snapshot.version]);
|
|
41
|
+
|
|
42
|
+
const visibleError =
|
|
43
|
+
snapshot.version > dismissedVersion ? snapshot.error : null;
|
|
44
|
+
|
|
45
|
+
const resetError = useCallback(() => {
|
|
46
|
+
setDismissedVersion(snapshot.version);
|
|
47
|
+
}, [snapshot.version]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<>
|
|
51
|
+
{children}
|
|
52
|
+
{visibleError ? fallback(visibleError, resetError) : null}
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { Match, NotFound } from "./components";
|
|
4
|
+
import { buildRenderList, collectElements } from "./helpers";
|
|
5
|
+
import { useRouteNode } from "../../../hooks/useRouteNode";
|
|
6
|
+
|
|
7
|
+
import type { RouteViewProps } from "./types";
|
|
8
|
+
import type { ReactElement } from "react";
|
|
9
|
+
|
|
10
|
+
function RouteViewRoot({
|
|
11
|
+
nodeName,
|
|
12
|
+
children,
|
|
13
|
+
}: Readonly<RouteViewProps>): ReactElement | null {
|
|
14
|
+
const { route } = useRouteNode(nodeName);
|
|
15
|
+
const hasBeenActivatedRef = useRef<Set<string>>(new Set());
|
|
16
|
+
|
|
17
|
+
if (!route) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const elements: ReactElement[] = [];
|
|
22
|
+
|
|
23
|
+
collectElements(children, elements);
|
|
24
|
+
|
|
25
|
+
const { rendered } = buildRenderList(
|
|
26
|
+
elements,
|
|
27
|
+
route.name,
|
|
28
|
+
nodeName,
|
|
29
|
+
// eslint-disable-next-line @eslint-react/refs -- stable Set ref read for keepAlive tracking (never reassigned)
|
|
30
|
+
hasBeenActivatedRef.current,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (rendered.length > 0) {
|
|
34
|
+
return <>{rendered}</>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
RouteViewRoot.displayName = "RouteView";
|
|
41
|
+
|
|
42
|
+
export const RouteView = Object.assign(RouteViewRoot, { Match, NotFound });
|
|
43
|
+
|
|
44
|
+
export type {
|
|
45
|
+
RouteViewProps,
|
|
46
|
+
MatchProps as RouteViewMatchProps,
|
|
47
|
+
NotFoundProps as RouteViewNotFoundProps,
|
|
48
|
+
} from "./types";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MatchProps, NotFoundProps } from "./types";
|
|
2
|
+
|
|
3
|
+
export function Match(_props: MatchProps): null {
|
|
4
|
+
return null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
Match.displayName = "RouteView.Match";
|
|
8
|
+
|
|
9
|
+
export function NotFound(_props: NotFoundProps): null {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
NotFound.displayName = "RouteView.NotFound";
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { UNKNOWN_ROUTE } from "@real-router/core";
|
|
2
|
+
import { startsWithSegment } from "@real-router/route-utils";
|
|
3
|
+
import { Activity, Children, Fragment, Suspense, isValidElement } from "react";
|
|
4
|
+
|
|
5
|
+
import { Match, NotFound } from "./components";
|
|
6
|
+
|
|
7
|
+
import type { MatchProps, NotFoundProps } from "./types";
|
|
8
|
+
import type { ReactElement, ReactNode } from "react";
|
|
9
|
+
|
|
10
|
+
function isSegmentMatch(
|
|
11
|
+
routeName: string,
|
|
12
|
+
fullSegmentName: string,
|
|
13
|
+
exact: boolean,
|
|
14
|
+
): boolean {
|
|
15
|
+
if (exact) {
|
|
16
|
+
return routeName === fullSegmentName;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return startsWithSegment(routeName, fullSegmentName);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function collectElements(
|
|
23
|
+
children: ReactNode,
|
|
24
|
+
result: ReactElement[],
|
|
25
|
+
): void {
|
|
26
|
+
// eslint-disable-next-line @eslint-react/no-children-to-array
|
|
27
|
+
for (const child of Children.toArray(children)) {
|
|
28
|
+
if (!isValidElement(child)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (child.type === Match || child.type === NotFound) {
|
|
33
|
+
result.push(child);
|
|
34
|
+
} else {
|
|
35
|
+
collectElements(
|
|
36
|
+
(child.props as { readonly children: ReactNode }).children,
|
|
37
|
+
result,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderMatchElement(
|
|
44
|
+
matchChildren: ReactNode,
|
|
45
|
+
fullSegmentName: string,
|
|
46
|
+
keepAlive: boolean,
|
|
47
|
+
mode: "visible" | "hidden",
|
|
48
|
+
fallback?: ReactNode,
|
|
49
|
+
): ReactElement {
|
|
50
|
+
const content =
|
|
51
|
+
fallback === undefined ? (
|
|
52
|
+
matchChildren
|
|
53
|
+
) : (
|
|
54
|
+
<Suspense fallback={fallback}>{matchChildren}</Suspense>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (keepAlive) {
|
|
58
|
+
return (
|
|
59
|
+
<Activity mode={mode} key={fullSegmentName}>
|
|
60
|
+
{content}
|
|
61
|
+
</Activity>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return <Fragment key={fullSegmentName}>{content}</Fragment>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildRenderList(
|
|
69
|
+
elements: ReactElement[],
|
|
70
|
+
routeName: string,
|
|
71
|
+
nodeName: string,
|
|
72
|
+
hasBeenActivated: Set<string>,
|
|
73
|
+
): { rendered: ReactElement[]; activeMatchFound: boolean } {
|
|
74
|
+
let notFoundChildren: ReactNode = null;
|
|
75
|
+
let activeMatchFound = false;
|
|
76
|
+
const rendered: ReactElement[] = [];
|
|
77
|
+
|
|
78
|
+
for (const child of elements) {
|
|
79
|
+
if (child.type === NotFound) {
|
|
80
|
+
notFoundChildren = (child.props as NotFoundProps).children;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const {
|
|
85
|
+
segment,
|
|
86
|
+
exact = false,
|
|
87
|
+
keepAlive = false,
|
|
88
|
+
fallback,
|
|
89
|
+
} = child.props as MatchProps;
|
|
90
|
+
const fullSegmentName = nodeName ? `${nodeName}.${segment}` : segment;
|
|
91
|
+
const isActive =
|
|
92
|
+
!activeMatchFound && isSegmentMatch(routeName, fullSegmentName, exact);
|
|
93
|
+
|
|
94
|
+
if (isActive) {
|
|
95
|
+
activeMatchFound = true;
|
|
96
|
+
hasBeenActivated.add(fullSegmentName);
|
|
97
|
+
rendered.push(
|
|
98
|
+
renderMatchElement(
|
|
99
|
+
(child.props as MatchProps).children,
|
|
100
|
+
fullSegmentName,
|
|
101
|
+
keepAlive,
|
|
102
|
+
"visible",
|
|
103
|
+
fallback,
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
} else if (keepAlive && hasBeenActivated.has(fullSegmentName)) {
|
|
107
|
+
rendered.push(
|
|
108
|
+
renderMatchElement(
|
|
109
|
+
(child.props as MatchProps).children,
|
|
110
|
+
fullSegmentName,
|
|
111
|
+
keepAlive,
|
|
112
|
+
"hidden",
|
|
113
|
+
fallback,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
!activeMatchFound &&
|
|
121
|
+
routeName === UNKNOWN_ROUTE &&
|
|
122
|
+
notFoundChildren !== null
|
|
123
|
+
) {
|
|
124
|
+
rendered.push(
|
|
125
|
+
<Fragment key="__route-view-not-found__">{notFoundChildren}</Fragment>,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { rendered, activeMatchFound };
|
|
130
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface RouteViewProps {
|
|
4
|
+
/** Route tree node name to subscribe to. "" for root. */
|
|
5
|
+
readonly nodeName: string;
|
|
6
|
+
/** <RouteView.Match> and <RouteView.NotFound> elements. */
|
|
7
|
+
readonly children: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MatchProps {
|
|
11
|
+
/** Route segment to match against. */
|
|
12
|
+
readonly segment: string;
|
|
13
|
+
/** Exact match only (no descendants). Defaults to false. */
|
|
14
|
+
readonly exact?: boolean;
|
|
15
|
+
/** Preserve component state when deactivated (React Activity). Defaults to false. */
|
|
16
|
+
readonly keepAlive?: boolean;
|
|
17
|
+
/** Fallback content to show while children are suspended. */
|
|
18
|
+
readonly fallback?: ReactNode;
|
|
19
|
+
/** Content to render when matched. */
|
|
20
|
+
readonly children: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface NotFoundProps {
|
|
24
|
+
/** Content to render on UNKNOWN_ROUTE. */
|
|
25
|
+
readonly children: ReactNode;
|
|
26
|
+
}
|
package/src/constants.ts
ADDED
package/src/context.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// packages/react/modules/context.ts
|
|
2
|
+
|
|
3
|
+
import { createContext } from "react";
|
|
4
|
+
|
|
5
|
+
import type { RouteContext as RouteContextType } from "./types";
|
|
6
|
+
import type { Router, Navigator } from "@real-router/core";
|
|
7
|
+
|
|
8
|
+
export const RouteContext = createContext<RouteContextType | null>(null);
|
|
9
|
+
|
|
10
|
+
export const RouterContext = createContext<Router | null>(null);
|
|
11
|
+
|
|
12
|
+
export const NavigatorContext = createContext<Navigator | null>(null);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createActiveRouteSource } from "@real-router/sources";
|
|
2
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
3
|
+
|
|
4
|
+
import { useRouter } from "./useRouter";
|
|
5
|
+
import { useStableValue } from "./useStableValue";
|
|
6
|
+
|
|
7
|
+
import type { Params } from "@real-router/core";
|
|
8
|
+
|
|
9
|
+
export function useIsActiveRoute(
|
|
10
|
+
routeName: string,
|
|
11
|
+
params?: Params,
|
|
12
|
+
strict = false,
|
|
13
|
+
ignoreQueryParams = true,
|
|
14
|
+
): boolean {
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
|
|
17
|
+
// useStableValue: JSON.stringify memoization of params object.
|
|
18
|
+
// Without it, every render with a new params reference (e.g.,
|
|
19
|
+
// <Link routeParams={{ id: '123' }} />) would recreate the store.
|
|
20
|
+
const stableParams = useStableValue(params);
|
|
21
|
+
|
|
22
|
+
const store = useMemo(
|
|
23
|
+
() =>
|
|
24
|
+
createActiveRouteSource(router, routeName, stableParams, {
|
|
25
|
+
strict,
|
|
26
|
+
ignoreQueryParams,
|
|
27
|
+
}),
|
|
28
|
+
[router, routeName, stableParams, strict, ignoreQueryParams],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return useSyncExternalStore(
|
|
32
|
+
store.subscribe,
|
|
33
|
+
store.getSnapshot,
|
|
34
|
+
store.getSnapshot, // SSR: router returns same state on server and client
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// packages/react/modules/hooks/useNavigator.tsx
|
|
2
|
+
|
|
3
|
+
import { useContext } from "react";
|
|
4
|
+
|
|
5
|
+
import { NavigatorContext } from "../context";
|
|
6
|
+
|
|
7
|
+
import type { Navigator } from "@real-router/core";
|
|
8
|
+
|
|
9
|
+
export const useNavigator = (): Navigator => {
|
|
10
|
+
const navigator = useContext(NavigatorContext);
|
|
11
|
+
|
|
12
|
+
if (!navigator) {
|
|
13
|
+
throw new Error("useNavigator must be used within a RouterProvider");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return navigator;
|
|
17
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// packages/react/modules/hooks/useRoute.tsx
|
|
2
|
+
|
|
3
|
+
import { useContext } from "react";
|
|
4
|
+
|
|
5
|
+
import { RouteContext } from "../context";
|
|
6
|
+
|
|
7
|
+
import type { RouteContext as RouteContextType } from "../types";
|
|
8
|
+
|
|
9
|
+
export const useRoute = (): RouteContextType => {
|
|
10
|
+
const routeContext = useContext(RouteContext);
|
|
11
|
+
|
|
12
|
+
if (!routeContext) {
|
|
13
|
+
throw new Error("useRoute must be used within a RouteProvider");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return routeContext;
|
|
17
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getNavigator } from "@real-router/core";
|
|
2
|
+
import { createRouteNodeSource } from "@real-router/sources";
|
|
3
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
import { useRouter } from "./useRouter";
|
|
6
|
+
|
|
7
|
+
import type { RouteContext } from "../types";
|
|
8
|
+
|
|
9
|
+
export function useRouteNode(nodeName: string): RouteContext {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
|
|
12
|
+
const store = useMemo(
|
|
13
|
+
() => createRouteNodeSource(router, nodeName),
|
|
14
|
+
[router, nodeName],
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const { route, previousRoute } = useSyncExternalStore(
|
|
18
|
+
store.subscribe,
|
|
19
|
+
store.getSnapshot,
|
|
20
|
+
store.getSnapshot, // SSR: router returns same state on server and client
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const navigator = useMemo(() => getNavigator(router), [router]);
|
|
24
|
+
|
|
25
|
+
return useMemo(
|
|
26
|
+
(): RouteContext => ({ navigator, route, previousRoute }),
|
|
27
|
+
[navigator, route, previousRoute],
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// packages/react/modules/hooks/useRouteUtils.tsx
|
|
2
|
+
|
|
3
|
+
import { getPluginApi } from "@real-router/core/api";
|
|
4
|
+
import { getRouteUtils } from "@real-router/route-utils";
|
|
5
|
+
|
|
6
|
+
import { useRouter } from "./useRouter";
|
|
7
|
+
|
|
8
|
+
import type { RouteUtils } from "@real-router/route-utils";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Returns a pre-computed {@link RouteUtils} instance for the current router.
|
|
12
|
+
*
|
|
13
|
+
* Internally retrieves the route tree via `getPluginApi` and delegates
|
|
14
|
+
* to `getRouteUtils`, which caches instances per tree reference (WeakMap).
|
|
15
|
+
*
|
|
16
|
+
* @returns RouteUtils instance with pre-computed chains and siblings
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* const utils = useRouteUtils();
|
|
21
|
+
*
|
|
22
|
+
* utils.getChain("users.profile");
|
|
23
|
+
* // → ["users", "users.profile"]
|
|
24
|
+
*
|
|
25
|
+
* utils.getSiblings("users");
|
|
26
|
+
* // → ["admin"]
|
|
27
|
+
*
|
|
28
|
+
* utils.isDescendantOf("users.profile", "users");
|
|
29
|
+
* // → true
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export const useRouteUtils = (): RouteUtils => {
|
|
33
|
+
const router = useRouter();
|
|
34
|
+
|
|
35
|
+
return getRouteUtils(getPluginApi(router).getTree());
|
|
36
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// packages/react/modules/hooks/useRouter.tsx
|
|
2
|
+
|
|
3
|
+
import { useContext } from "react";
|
|
4
|
+
|
|
5
|
+
import { RouterContext } from "../context";
|
|
6
|
+
|
|
7
|
+
import type { Router } from "@real-router/core";
|
|
8
|
+
|
|
9
|
+
export const useRouter = (): Router => {
|
|
10
|
+
const router = useContext(RouterContext);
|
|
11
|
+
|
|
12
|
+
if (!router) {
|
|
13
|
+
throw new Error("useRouter must be used within a RouterProvider");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return router;
|
|
17
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createErrorSource } from "@real-router/sources";
|
|
2
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
3
|
+
|
|
4
|
+
import { useRouter } from "./useRouter";
|
|
5
|
+
|
|
6
|
+
import type { Router } from "@real-router/core";
|
|
7
|
+
import type { RouterErrorSnapshot, RouterSource } from "@real-router/sources";
|
|
8
|
+
|
|
9
|
+
const cache = new WeakMap<Router, RouterSource<RouterErrorSnapshot>>();
|
|
10
|
+
|
|
11
|
+
export function useRouterError(): RouterErrorSnapshot {
|
|
12
|
+
const router = useRouter();
|
|
13
|
+
const store = useMemo(() => {
|
|
14
|
+
let source = cache.get(router);
|
|
15
|
+
|
|
16
|
+
if (!source) {
|
|
17
|
+
source = createErrorSource(router);
|
|
18
|
+
cache.set(router, source);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return source;
|
|
22
|
+
}, [router]);
|
|
23
|
+
|
|
24
|
+
return useSyncExternalStore(
|
|
25
|
+
store.subscribe,
|
|
26
|
+
store.getSnapshot,
|
|
27
|
+
store.getSnapshot,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createTransitionSource } from "@real-router/sources";
|
|
2
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
3
|
+
|
|
4
|
+
import { useRouter } from "./useRouter";
|
|
5
|
+
|
|
6
|
+
import type { RouterTransitionSnapshot } from "@real-router/sources";
|
|
7
|
+
|
|
8
|
+
export function useRouterTransition(): RouterTransitionSnapshot {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
|
|
11
|
+
const store = useMemo(() => createTransitionSource(router), [router]);
|
|
12
|
+
|
|
13
|
+
return useSyncExternalStore(
|
|
14
|
+
store.subscribe,
|
|
15
|
+
store.getSnapshot,
|
|
16
|
+
store.getSnapshot,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// packages/react/modules/hooks/useStableValue.tsx
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stabilizes a value reference based on deep equality (via JSON serialization).
|
|
7
|
+
* Returns the same reference until the serialized value changes.
|
|
8
|
+
*
|
|
9
|
+
* Useful for object/array dependencies in hooks like useMemo, useCallback, useEffect
|
|
10
|
+
* to prevent unnecessary re-renders when the value is structurally the same.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const stableParams = useStableValue(routeParams);
|
|
15
|
+
* const href = useMemo(() => {
|
|
16
|
+
* return router.buildUrl(routeName, stableParams);
|
|
17
|
+
* }, [router, routeName, stableParams]);
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @param value - The value to stabilize
|
|
21
|
+
* @returns A stable reference to the value
|
|
22
|
+
*/
|
|
23
|
+
export function useStableValue<T>(value: T): T {
|
|
24
|
+
const serialized = JSON.stringify(value);
|
|
25
|
+
|
|
26
|
+
// We intentionally use serialized in deps to detect deep changes
|
|
27
|
+
// eslint-disable-next-line @eslint-react/exhaustive-deps
|
|
28
|
+
return useMemo(() => value, [serialized]);
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Main entry point — React 19.2+
|
|
2
|
+
|
|
3
|
+
// Components
|
|
4
|
+
export { RouteView } from "./components/modern/RouteView";
|
|
5
|
+
|
|
6
|
+
export { Link } from "./components/Link";
|
|
7
|
+
|
|
8
|
+
export { RouterErrorBoundary } from "./components/RouterErrorBoundary";
|
|
9
|
+
|
|
10
|
+
// Hooks
|
|
11
|
+
export { useRouter } from "./hooks/useRouter";
|
|
12
|
+
|
|
13
|
+
export { useNavigator } from "./hooks/useNavigator";
|
|
14
|
+
|
|
15
|
+
export { useRouteUtils } from "./hooks/useRouteUtils";
|
|
16
|
+
|
|
17
|
+
export { useRoute } from "./hooks/useRoute";
|
|
18
|
+
|
|
19
|
+
export { useRouteNode } from "./hooks/useRouteNode";
|
|
20
|
+
|
|
21
|
+
export { useRouterTransition } from "./hooks/useRouterTransition";
|
|
22
|
+
|
|
23
|
+
// Context
|
|
24
|
+
export { RouterProvider } from "./RouterProvider";
|
|
25
|
+
|
|
26
|
+
// Types
|
|
27
|
+
export type { LinkProps } from "./types";
|
|
28
|
+
|
|
29
|
+
export type {
|
|
30
|
+
RouteViewProps,
|
|
31
|
+
RouteViewMatchProps,
|
|
32
|
+
RouteViewNotFoundProps,
|
|
33
|
+
} from "./components/modern/RouteView";
|
|
34
|
+
|
|
35
|
+
export type { RouterErrorBoundaryProps } from "./components/RouterErrorBoundary";
|
|
36
|
+
|
|
37
|
+
export type { Navigator } from "@real-router/core";
|
|
38
|
+
|
|
39
|
+
export type { RouterTransitionSnapshot } from "@real-router/sources";
|
package/src/legacy.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Legacy entry point — React 18+
|
|
2
|
+
|
|
3
|
+
// Components
|
|
4
|
+
export { Link } from "./components/Link";
|
|
5
|
+
|
|
6
|
+
export { RouterErrorBoundary } from "./components/RouterErrorBoundary";
|
|
7
|
+
|
|
8
|
+
// Hooks
|
|
9
|
+
export { useRouteNode } from "./hooks/useRouteNode";
|
|
10
|
+
|
|
11
|
+
export { useRoute } from "./hooks/useRoute";
|
|
12
|
+
|
|
13
|
+
export { useNavigator } from "./hooks/useNavigator";
|
|
14
|
+
|
|
15
|
+
export { useRouter } from "./hooks/useRouter";
|
|
16
|
+
|
|
17
|
+
export { useRouteUtils } from "./hooks/useRouteUtils";
|
|
18
|
+
|
|
19
|
+
export { useRouterTransition } from "./hooks/useRouterTransition";
|
|
20
|
+
|
|
21
|
+
// Context
|
|
22
|
+
export { RouterProvider } from "./RouterProvider";
|
|
23
|
+
|
|
24
|
+
// Types
|
|
25
|
+
export type { LinkProps } from "./types";
|
|
26
|
+
|
|
27
|
+
export type { RouterErrorBoundaryProps } from "./components/RouterErrorBoundary";
|
|
28
|
+
|
|
29
|
+
export type { Navigator } from "@real-router/core";
|
|
30
|
+
|
|
31
|
+
export type { RouterTransitionSnapshot } from "@real-router/sources";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NavigationOptions,
|
|
3
|
+
Params,
|
|
4
|
+
Navigator,
|
|
5
|
+
State,
|
|
6
|
+
} from "@real-router/core";
|
|
7
|
+
import type { HTMLAttributes, MouseEventHandler } from "react";
|
|
8
|
+
|
|
9
|
+
export interface RouteState<P extends Params = Params> {
|
|
10
|
+
route: State<P> | undefined;
|
|
11
|
+
previousRoute?: State | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type RouteContext = {
|
|
15
|
+
navigator: Navigator;
|
|
16
|
+
} & RouteState;
|
|
17
|
+
|
|
18
|
+
export interface LinkProps<
|
|
19
|
+
P extends Params = Params,
|
|
20
|
+
> extends HTMLAttributes<HTMLAnchorElement> {
|
|
21
|
+
routeName: string;
|
|
22
|
+
routeParams?: P;
|
|
23
|
+
routeOptions?: NavigationOptions;
|
|
24
|
+
activeClassName?: string;
|
|
25
|
+
activeStrict?: boolean;
|
|
26
|
+
ignoreQueryParams?: boolean;
|
|
27
|
+
target?: string;
|
|
28
|
+
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
29
|
+
onMouseOver?: MouseEventHandler<HTMLAnchorElement>;
|
|
30
|
+
}
|