@real-router/react 0.4.3 → 0.4.4
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/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/metafile-cjs.json +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/metafile-esm.json +1 -1
- package/package.json +9 -7
- package/src/RouterProvider.tsx +64 -0
- package/src/components/BaseLink.tsx +130 -0
- package/src/components/ConnectedLink.tsx +20 -0
- package/src/components/Link.tsx +16 -0
- package/src/components/interfaces.ts +28 -0
- package/src/constants.ts +11 -0
- package/src/context.ts +12 -0
- package/src/hooks/useIsActiveRoute.tsx +95 -0
- package/src/hooks/useNavigator.tsx +17 -0
- package/src/hooks/useRoute.tsx +17 -0
- package/src/hooks/useRouteNode.tsx +74 -0
- package/src/hooks/useRouter.tsx +17 -0
- package/src/hooks/useRouterSubscription.tsx +81 -0
- package/src/hooks/useStableValue.tsx +29 -0
- package/src/index.ts +27 -0
- package/src/types.ts +36 -0
- package/src/utils.ts +71 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// packages/react/modules/RouterProvider.tsx
|
|
2
|
+
|
|
3
|
+
import { getNavigator } from "@real-router/core";
|
|
4
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
5
|
+
|
|
6
|
+
import { NavigatorContext, RouteContext, RouterContext } from "./context";
|
|
7
|
+
|
|
8
|
+
import type { RouteState } from "./types";
|
|
9
|
+
import type { Router } from "@real-router/core";
|
|
10
|
+
import type { FC, ReactNode } from "react";
|
|
11
|
+
|
|
12
|
+
export interface RouteProviderProps {
|
|
13
|
+
router: Router;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const RouterProvider: FC<RouteProviderProps> = ({
|
|
18
|
+
router,
|
|
19
|
+
children,
|
|
20
|
+
}) => {
|
|
21
|
+
// Get navigator instance from router
|
|
22
|
+
const navigator = useMemo(() => getNavigator(router), [router]);
|
|
23
|
+
|
|
24
|
+
// Local store state to hold route information
|
|
25
|
+
const store = useMemo(() => {
|
|
26
|
+
let currentState: RouteState = {
|
|
27
|
+
route: router.getState(),
|
|
28
|
+
previousRoute: undefined,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// This will be called to return the current state snapshot
|
|
32
|
+
const getSnapshot = () => currentState;
|
|
33
|
+
|
|
34
|
+
// Subscribe to router updates and notify React when state changes
|
|
35
|
+
const subscribe = (callback: () => void) => {
|
|
36
|
+
const unsubscribe = router.subscribe(({ route, previousRoute }) => {
|
|
37
|
+
currentState = { route, previousRoute };
|
|
38
|
+
callback(); // Notify React to trigger re-render
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Note: router.subscribe() always returns a function, no need to check
|
|
42
|
+
return unsubscribe;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return { getSnapshot, subscribe };
|
|
46
|
+
}, [router]);
|
|
47
|
+
|
|
48
|
+
// Using useSyncExternalStore to manage subscription and state updates
|
|
49
|
+
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
50
|
+
|
|
51
|
+
// Memoize RouteContext value to prevent unnecessary re-renders
|
|
52
|
+
const routeContextValue = useMemo(
|
|
53
|
+
() => ({ navigator, ...state }),
|
|
54
|
+
[navigator, state],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<RouterContext value={router}>
|
|
59
|
+
<NavigatorContext value={navigator}>
|
|
60
|
+
<RouteContext value={routeContextValue}>{children}</RouteContext>
|
|
61
|
+
</NavigatorContext>
|
|
62
|
+
</RouterContext>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// packages/react/modules/components/BaseLink.tsx
|
|
2
|
+
|
|
3
|
+
import { memo, useCallback, useMemo } from "react";
|
|
4
|
+
|
|
5
|
+
import { EMPTY_PARAMS, EMPTY_OPTIONS } from "../constants";
|
|
6
|
+
import { useIsActiveRoute } from "../hooks/useIsActiveRoute";
|
|
7
|
+
import { useStableValue } from "../hooks/useStableValue";
|
|
8
|
+
import { shouldNavigate } from "../utils";
|
|
9
|
+
|
|
10
|
+
import type { BaseLinkProps } from "../types";
|
|
11
|
+
import type { FC, MouseEvent } from "react";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Optimized BaseLink component with memoization and performance improvements
|
|
15
|
+
*/
|
|
16
|
+
export const BaseLink: FC<BaseLinkProps> = memo(
|
|
17
|
+
({
|
|
18
|
+
routeName,
|
|
19
|
+
routeParams = EMPTY_PARAMS,
|
|
20
|
+
routeOptions = EMPTY_OPTIONS,
|
|
21
|
+
className,
|
|
22
|
+
activeClassName = "active",
|
|
23
|
+
activeStrict = false,
|
|
24
|
+
ignoreQueryParams = true,
|
|
25
|
+
onClick,
|
|
26
|
+
target,
|
|
27
|
+
router,
|
|
28
|
+
children,
|
|
29
|
+
...props
|
|
30
|
+
}) => {
|
|
31
|
+
// Stabilize object references to prevent unnecessary re-renders
|
|
32
|
+
const stableParams = useStableValue(routeParams);
|
|
33
|
+
const stableOptions = useStableValue(routeOptions);
|
|
34
|
+
|
|
35
|
+
// Use optimized hook for active state checking
|
|
36
|
+
const isActive = useIsActiveRoute(
|
|
37
|
+
router,
|
|
38
|
+
routeName,
|
|
39
|
+
stableParams,
|
|
40
|
+
activeStrict,
|
|
41
|
+
ignoreQueryParams,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Build URL with memoization
|
|
45
|
+
const href = useMemo(() => {
|
|
46
|
+
// Use buildUrl if available (browser plugin installed)
|
|
47
|
+
// Otherwise fall back to buildPath (e.g., in SSR or without browser plugin)
|
|
48
|
+
if (typeof router.buildUrl === "function") {
|
|
49
|
+
return router.buildUrl(routeName, stableParams);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return router.buildPath(routeName, stableParams);
|
|
53
|
+
}, [router, routeName, stableParams]);
|
|
54
|
+
|
|
55
|
+
// Optimized click handler
|
|
56
|
+
const handleClick = useCallback(
|
|
57
|
+
(evt: MouseEvent<HTMLAnchorElement>) => {
|
|
58
|
+
// Call custom onClick if provided
|
|
59
|
+
if (onClick) {
|
|
60
|
+
onClick(evt);
|
|
61
|
+
// Respect preventDefault from custom handler
|
|
62
|
+
if (evt.defaultPrevented) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check if we should handle navigation
|
|
68
|
+
if (!shouldNavigate(evt) || target === "_blank") {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Prevent default link behavior
|
|
73
|
+
evt.preventDefault();
|
|
74
|
+
|
|
75
|
+
// Perform navigation (fire-and-forget)
|
|
76
|
+
router.navigate(routeName, stableParams, stableOptions).catch(() => {});
|
|
77
|
+
},
|
|
78
|
+
[onClick, target, router, routeName, stableParams, stableOptions],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Build className efficiently
|
|
82
|
+
const finalClassName = useMemo(() => {
|
|
83
|
+
if (isActive && activeClassName) {
|
|
84
|
+
return className
|
|
85
|
+
? `${className} ${activeClassName}`.trim()
|
|
86
|
+
: activeClassName;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return className ?? undefined;
|
|
90
|
+
}, [isActive, className, activeClassName]);
|
|
91
|
+
|
|
92
|
+
// Filter out previousRoute from props
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
94
|
+
const { previousRoute, ...restProps } = props;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<a
|
|
98
|
+
{...restProps}
|
|
99
|
+
href={href}
|
|
100
|
+
className={finalClassName}
|
|
101
|
+
onClick={handleClick}
|
|
102
|
+
data-route={routeName} // For event delegation if needed
|
|
103
|
+
data-active={isActive} // For CSS selectors if needed
|
|
104
|
+
>
|
|
105
|
+
{children}
|
|
106
|
+
</a>
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
(prevProps, nextProps) => {
|
|
110
|
+
// Custom comparison for better memoization
|
|
111
|
+
// Check if props that affect rendering have changed
|
|
112
|
+
return (
|
|
113
|
+
prevProps.router === nextProps.router &&
|
|
114
|
+
prevProps.routeName === nextProps.routeName &&
|
|
115
|
+
JSON.stringify(prevProps.routeParams) ===
|
|
116
|
+
JSON.stringify(nextProps.routeParams) &&
|
|
117
|
+
JSON.stringify(prevProps.routeOptions) ===
|
|
118
|
+
JSON.stringify(nextProps.routeOptions) &&
|
|
119
|
+
prevProps.className === nextProps.className &&
|
|
120
|
+
prevProps.activeClassName === nextProps.activeClassName &&
|
|
121
|
+
prevProps.activeStrict === nextProps.activeStrict &&
|
|
122
|
+
prevProps.ignoreQueryParams === nextProps.ignoreQueryParams &&
|
|
123
|
+
prevProps.onClick === nextProps.onClick &&
|
|
124
|
+
prevProps.target === nextProps.target &&
|
|
125
|
+
prevProps.children === nextProps.children
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
BaseLink.displayName = "BaseLink";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// packages/react/modules/components/ConnectedLink.tsx
|
|
2
|
+
|
|
3
|
+
import { BaseLink } from "./BaseLink";
|
|
4
|
+
import { useRoute } from "../hooks/useRoute";
|
|
5
|
+
import { useRouter } from "../hooks/useRouter";
|
|
6
|
+
|
|
7
|
+
import type { BaseLinkProps } from "./interfaces";
|
|
8
|
+
import type { FC } from "react";
|
|
9
|
+
|
|
10
|
+
export const ConnectedLink: FC<
|
|
11
|
+
Omit<BaseLinkProps, "router" | "route" | "previousRoute">
|
|
12
|
+
> = (props) => {
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
const { route } = useRoute();
|
|
15
|
+
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
17
|
+
const { routeOptions, ...linkProps } = props;
|
|
18
|
+
|
|
19
|
+
return <BaseLink router={router} route={route} {...linkProps} />;
|
|
20
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// packages/react/modules/components/Link.tsx
|
|
2
|
+
|
|
3
|
+
import { BaseLink } from "./BaseLink";
|
|
4
|
+
import { useRouter } from "../hooks/useRouter";
|
|
5
|
+
|
|
6
|
+
import type { BaseLinkProps } from "./interfaces";
|
|
7
|
+
import type { FC } from "react";
|
|
8
|
+
|
|
9
|
+
export const Link: FC<Omit<BaseLinkProps, "router">> = (props) => {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
13
|
+
const { route, previousRoute, routeOptions, ...linkProps } = props;
|
|
14
|
+
|
|
15
|
+
return <BaseLink router={router} {...linkProps} />;
|
|
16
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// packages/react/modules/components/interfaces.ts
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
NavigationOptions,
|
|
5
|
+
Params,
|
|
6
|
+
Router,
|
|
7
|
+
State,
|
|
8
|
+
} from "@real-router/core";
|
|
9
|
+
import type { HTMLAttributes, MouseEventHandler } from "react";
|
|
10
|
+
|
|
11
|
+
export interface BaseLinkProps<
|
|
12
|
+
P extends Params = Params,
|
|
13
|
+
MP extends Params = Params,
|
|
14
|
+
> extends HTMLAttributes<HTMLAnchorElement> {
|
|
15
|
+
router: Router;
|
|
16
|
+
routeName: string;
|
|
17
|
+
route?: State<P, MP> | undefined;
|
|
18
|
+
previousRoute?: State | undefined;
|
|
19
|
+
routeParams?: P;
|
|
20
|
+
routeOptions?: NavigationOptions;
|
|
21
|
+
className?: string;
|
|
22
|
+
activeClassName?: string;
|
|
23
|
+
activeStrict?: boolean;
|
|
24
|
+
ignoreQueryParams?: boolean;
|
|
25
|
+
target?: string;
|
|
26
|
+
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
27
|
+
onMouseOver?: MouseEventHandler<HTMLAnchorElement>;
|
|
28
|
+
}
|
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,95 @@
|
|
|
1
|
+
// packages/react/modules/hooks/useIsActiveRoute.tsx
|
|
2
|
+
|
|
3
|
+
import { areRoutesRelated } from "@real-router/helpers";
|
|
4
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
5
|
+
|
|
6
|
+
import { useRouterSubscription } from "./useRouterSubscription";
|
|
7
|
+
import { useStableValue } from "./useStableValue";
|
|
8
|
+
import { EMPTY_PARAMS } from "../constants";
|
|
9
|
+
import { createActiveCheckKey } from "../utils";
|
|
10
|
+
|
|
11
|
+
import type { Params, Router, State, SubscribeState } from "@real-router/core";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Optimized hook to check if a route is active.
|
|
15
|
+
* Minimizes unnecessary recalculations and re-renders.
|
|
16
|
+
*/
|
|
17
|
+
export function useIsActiveRoute(
|
|
18
|
+
router: Router,
|
|
19
|
+
routeName: string,
|
|
20
|
+
routeParams: Params = EMPTY_PARAMS,
|
|
21
|
+
activeStrict = false,
|
|
22
|
+
ignoreQueryParams = true,
|
|
23
|
+
): boolean {
|
|
24
|
+
// Stabilize params reference to prevent unnecessary recalculations
|
|
25
|
+
const stableParams = useStableValue(routeParams);
|
|
26
|
+
|
|
27
|
+
// Create stable cache key
|
|
28
|
+
const cacheKey = useMemo(
|
|
29
|
+
() =>
|
|
30
|
+
createActiveCheckKey(
|
|
31
|
+
routeName,
|
|
32
|
+
stableParams,
|
|
33
|
+
activeStrict,
|
|
34
|
+
ignoreQueryParams,
|
|
35
|
+
),
|
|
36
|
+
[routeName, stableParams, activeStrict, ignoreQueryParams],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Cache the active state
|
|
40
|
+
const isActiveRef = useRef<boolean | undefined>(undefined);
|
|
41
|
+
const lastCacheKeyRef = useRef<string | undefined>(undefined);
|
|
42
|
+
|
|
43
|
+
if (lastCacheKeyRef.current !== cacheKey) {
|
|
44
|
+
isActiveRef.current = undefined;
|
|
45
|
+
lastCacheKeyRef.current = cacheKey;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Optimize shouldUpdate to skip unrelated routes
|
|
49
|
+
const shouldUpdate = useCallback(
|
|
50
|
+
(newRoute: State, prevRoute?: State) => {
|
|
51
|
+
const isNewRelated = areRoutesRelated(routeName, newRoute.name);
|
|
52
|
+
const isPrevRelated =
|
|
53
|
+
prevRoute && areRoutesRelated(routeName, prevRoute.name);
|
|
54
|
+
|
|
55
|
+
return !!(isNewRelated || isPrevRelated);
|
|
56
|
+
},
|
|
57
|
+
[routeName],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Selector that performs active check
|
|
61
|
+
const selector = useCallback(
|
|
62
|
+
(sub?: SubscribeState): boolean => {
|
|
63
|
+
const currentRoute = sub?.route ?? router.getState();
|
|
64
|
+
|
|
65
|
+
// Fast path: if no current route, not active
|
|
66
|
+
if (!currentRoute) {
|
|
67
|
+
isActiveRef.current = false;
|
|
68
|
+
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fast path: skip unrelated routes
|
|
73
|
+
if (!areRoutesRelated(routeName, currentRoute.name)) {
|
|
74
|
+
isActiveRef.current = false;
|
|
75
|
+
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Full check for related routes
|
|
80
|
+
const isActive = router.isActiveRoute(
|
|
81
|
+
routeName,
|
|
82
|
+
stableParams,
|
|
83
|
+
activeStrict,
|
|
84
|
+
ignoreQueryParams,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
isActiveRef.current = isActive;
|
|
88
|
+
|
|
89
|
+
return isActive;
|
|
90
|
+
},
|
|
91
|
+
[router, routeName, stableParams, activeStrict, ignoreQueryParams],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return useRouterSubscription(router, selector, shouldUpdate);
|
|
95
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// packages/react/modules/hooks/useNavigator.tsx
|
|
2
|
+
|
|
3
|
+
import { use } 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 = use(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 { use } 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 = use(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,74 @@
|
|
|
1
|
+
// packages/react/modules/hooks/useRouteNode.tsx
|
|
2
|
+
|
|
3
|
+
import { getNavigator } from "@real-router/core";
|
|
4
|
+
import { useCallback, useMemo } from "react";
|
|
5
|
+
|
|
6
|
+
import { useRouter } from "./useRouter";
|
|
7
|
+
import { useRouterSubscription } from "./useRouterSubscription";
|
|
8
|
+
import { getCachedShouldUpdate } from "../utils";
|
|
9
|
+
|
|
10
|
+
import type { RouteContext, RouteState } from "../types";
|
|
11
|
+
import type { State, SubscribeState } from "@real-router/core";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hook that subscribes to a specific route node with optimizations.
|
|
15
|
+
* Provides the current and previous route when the node is affected.
|
|
16
|
+
*/
|
|
17
|
+
export function useRouteNode(nodeName: string): RouteContext {
|
|
18
|
+
// Get router from context with error handling
|
|
19
|
+
const router = useRouter();
|
|
20
|
+
|
|
21
|
+
// Get cached shouldUpdate function to avoid recreation
|
|
22
|
+
const shouldUpdate = useMemo(
|
|
23
|
+
() => getCachedShouldUpdate(router, nodeName),
|
|
24
|
+
[router, nodeName],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Stable state factory
|
|
28
|
+
// useRouteNode.tsx
|
|
29
|
+
const stateFactory = useCallback(
|
|
30
|
+
(sub?: SubscribeState): RouteState => {
|
|
31
|
+
const currentRoute = sub?.route ?? router.getState();
|
|
32
|
+
|
|
33
|
+
// Проверяем, активен ли узел
|
|
34
|
+
if (currentRoute && nodeName !== "") {
|
|
35
|
+
// Корневой узел всегда активен
|
|
36
|
+
const isNodeActive =
|
|
37
|
+
currentRoute.name === nodeName ||
|
|
38
|
+
currentRoute.name.startsWith(`${nodeName}.`);
|
|
39
|
+
|
|
40
|
+
if (!isNodeActive) {
|
|
41
|
+
return {
|
|
42
|
+
route: undefined,
|
|
43
|
+
previousRoute: sub?.previousRoute,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
route: currentRoute,
|
|
50
|
+
previousRoute: sub?.previousRoute,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
[router, nodeName],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Subscribe to router with optimization
|
|
57
|
+
const state = useRouterSubscription<RouteState>(
|
|
58
|
+
router,
|
|
59
|
+
stateFactory,
|
|
60
|
+
shouldUpdate as (newRoute: State, prevRoute?: State) => boolean,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Return memoized context - useMemo ensures stable reference when deps unchanged
|
|
64
|
+
const navigator = useMemo(() => getNavigator(router), [router]);
|
|
65
|
+
|
|
66
|
+
return useMemo(
|
|
67
|
+
(): RouteContext => ({
|
|
68
|
+
navigator,
|
|
69
|
+
route: state.route,
|
|
70
|
+
previousRoute: state.previousRoute,
|
|
71
|
+
}),
|
|
72
|
+
[navigator, state.route, state.previousRoute],
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// packages/react/modules/hooks/useRouter.tsx
|
|
2
|
+
|
|
3
|
+
import { use } 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 = use(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,81 @@
|
|
|
1
|
+
// packages/react/modules/hooks/useRouterSubscription.tsx
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
import type { Router, State, SubscribeState } from "@real-router/core";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generic hook for subscribing to router changes with optimization.
|
|
9
|
+
*
|
|
10
|
+
* @param router - Real Router instance
|
|
11
|
+
* @param selector - Function to derive state from router subscription
|
|
12
|
+
* @param shouldUpdate - Optional predicate to filter updates
|
|
13
|
+
*/
|
|
14
|
+
export function useRouterSubscription<T>(
|
|
15
|
+
router: Router,
|
|
16
|
+
selector: (sub?: SubscribeState) => T,
|
|
17
|
+
shouldUpdate?: (newRoute: State, prevRoute?: State) => boolean,
|
|
18
|
+
): T {
|
|
19
|
+
// Store current value
|
|
20
|
+
const stateRef = useRef<T | undefined>(undefined);
|
|
21
|
+
const selectorRef = useRef(selector);
|
|
22
|
+
const shouldUpdateRef = useRef(shouldUpdate);
|
|
23
|
+
|
|
24
|
+
// Update refs to avoid stale closures
|
|
25
|
+
selectorRef.current = selector;
|
|
26
|
+
shouldUpdateRef.current = shouldUpdate;
|
|
27
|
+
|
|
28
|
+
// Lazy initialization
|
|
29
|
+
if (stateRef.current === undefined) {
|
|
30
|
+
// Get initial state from router
|
|
31
|
+
const currentState = router.getState();
|
|
32
|
+
|
|
33
|
+
// Check if initial state is relevant for this subscription
|
|
34
|
+
const shouldInitialize =
|
|
35
|
+
!shouldUpdateRef.current ||
|
|
36
|
+
(currentState && shouldUpdateRef.current(currentState));
|
|
37
|
+
|
|
38
|
+
stateRef.current = selectorRef.current(
|
|
39
|
+
shouldInitialize && currentState
|
|
40
|
+
? { route: currentState, previousRoute: undefined }
|
|
41
|
+
: undefined,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Stable snapshot getter
|
|
46
|
+
const getSnapshot = useCallback(() => stateRef.current as T, []);
|
|
47
|
+
|
|
48
|
+
// Subscribe function with optimization
|
|
49
|
+
const subscribe = useCallback(
|
|
50
|
+
(onStoreChange: () => void) => {
|
|
51
|
+
return router.subscribe((next) => {
|
|
52
|
+
// Check if we should process this update
|
|
53
|
+
let shouldProcess = true;
|
|
54
|
+
|
|
55
|
+
if (shouldUpdateRef.current) {
|
|
56
|
+
shouldProcess = shouldUpdateRef.current(
|
|
57
|
+
next.route,
|
|
58
|
+
next.previousRoute,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!shouldProcess) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Calculate new value
|
|
67
|
+
const newValue = selectorRef.current(next);
|
|
68
|
+
|
|
69
|
+
// Only trigger update if value actually changed
|
|
70
|
+
if (!Object.is(stateRef.current, newValue)) {
|
|
71
|
+
stateRef.current = newValue;
|
|
72
|
+
|
|
73
|
+
onStoreChange();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
[router],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
81
|
+
}
|
|
@@ -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 react-hooks/exhaustive-deps
|
|
28
|
+
return useMemo(() => value, [serialized]);
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// packages/react/modules/index.ts
|
|
2
|
+
|
|
3
|
+
// Components
|
|
4
|
+
export { BaseLink } from "./components/BaseLink";
|
|
5
|
+
|
|
6
|
+
export type { BaseLinkProps } from "./types";
|
|
7
|
+
|
|
8
|
+
export { Link } from "./components/Link";
|
|
9
|
+
|
|
10
|
+
export { ConnectedLink } from "./components/ConnectedLink";
|
|
11
|
+
|
|
12
|
+
// Hooks
|
|
13
|
+
export { useRouteNode } from "./hooks/useRouteNode";
|
|
14
|
+
|
|
15
|
+
export { useRoute } from "./hooks/useRoute";
|
|
16
|
+
|
|
17
|
+
export { useNavigator } from "./hooks/useNavigator";
|
|
18
|
+
|
|
19
|
+
// Context
|
|
20
|
+
export { RouterProvider } from "./RouterProvider";
|
|
21
|
+
|
|
22
|
+
export { RouterContext, RouteContext, NavigatorContext } from "./context";
|
|
23
|
+
|
|
24
|
+
export { useRouter } from "./hooks/useRouter";
|
|
25
|
+
|
|
26
|
+
// Types
|
|
27
|
+
export type { Navigator } from "@real-router/core";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// packages/react/modules/types.ts
|
|
2
|
+
|
|
3
|
+
import type { Params, Navigator, Router, State } from "@real-router/core";
|
|
4
|
+
import type { MouseEvent, ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
export interface RouteState<
|
|
7
|
+
P extends Params = Params,
|
|
8
|
+
MP extends Params = Params,
|
|
9
|
+
> {
|
|
10
|
+
route: State<P, MP> | undefined;
|
|
11
|
+
previousRoute?: State | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type RouteContext = {
|
|
15
|
+
navigator: Navigator;
|
|
16
|
+
} & RouteState;
|
|
17
|
+
|
|
18
|
+
export interface BaseLinkProps {
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
router: Router;
|
|
21
|
+
routeName: string;
|
|
22
|
+
routeParams?: Params;
|
|
23
|
+
routeOptions?: {
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
reload?: boolean;
|
|
26
|
+
replace?: boolean;
|
|
27
|
+
};
|
|
28
|
+
className?: string;
|
|
29
|
+
activeClassName?: string;
|
|
30
|
+
activeStrict?: boolean;
|
|
31
|
+
ignoreQueryParams?: boolean;
|
|
32
|
+
onClick?: (evt: MouseEvent<HTMLAnchorElement>) => void;
|
|
33
|
+
target?: string;
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
previousRoute?: State;
|
|
36
|
+
}
|