@real-router/react 0.4.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-router/react",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "type": "commonjs",
5
5
  "description": "React integration for Real-Router",
6
6
  "main": "./dist/cjs/index.js",
@@ -8,6 +8,7 @@
8
8
  "types": "./dist/esm/index.d.mts",
9
9
  "exports": {
10
10
  ".": {
11
+ "development": "./src/index.ts",
11
12
  "types": {
12
13
  "import": "./dist/esm/index.d.mts",
13
14
  "require": "./dist/cjs/index.d.ts"
@@ -17,7 +18,8 @@
17
18
  }
18
19
  },
19
20
  "files": [
20
- "dist"
21
+ "dist",
22
+ "src"
21
23
  ],
22
24
  "homepage": "https://github.com/greydragon888/real-router",
23
25
  "repository": {
@@ -46,22 +48,22 @@
46
48
  "license": "MIT",
47
49
  "sideEffects": false,
48
50
  "dependencies": {
49
- "@real-router/core": "^0.20.0",
50
- "@real-router/helpers": "^0.1.23"
51
+ "@real-router/core": "^0.22.0",
52
+ "@real-router/helpers": "^0.1.25"
51
53
  },
52
54
  "devDependencies": {
53
55
  "@testing-library/dom": "10.4.1",
54
56
  "@testing-library/jest-dom": "6.9.1",
55
57
  "@testing-library/react": "16.3.2",
56
58
  "@testing-library/user-event": "14.6.1",
57
- "vitest-react-profiler": "1.12.0"
59
+ "vitest-react-profiler": "1.12.0",
60
+ "@real-router/browser-plugin": "^0.4.0"
58
61
  },
59
62
  "peerDependencies": {
60
63
  "@types/react": ">=18.0.0",
61
64
  "@types/react-dom": ">=18.0.0",
62
65
  "react": ">=18.0.0",
63
- "react-dom": ">=18.0.0",
64
- "@real-router/browser-plugin": "^0.3.2"
66
+ "react-dom": ">=18.0.0"
65
67
  },
66
68
  "scripts": {
67
69
  "build": "tsup",
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ // packages/react/modules/constants.ts
2
+
3
+ /**
4
+ * Stable empty object for default params
5
+ */
6
+ export const EMPTY_PARAMS = Object.freeze({});
7
+
8
+ /**
9
+ * Stable empty options object
10
+ */
11
+ export const EMPTY_OPTIONS = Object.freeze({});
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
+ }