@funstack/router 0.0.1-alpha.1 → 0.0.1-alpha.2
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/index.d.mts +112 -45
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +183 -100
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -15
- package/README.md +0 -225
package/dist/index.d.mts
CHANGED
|
@@ -1,29 +1,133 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { AnchorHTMLAttributes, ComponentType, ReactNode } from "react";
|
|
1
|
+
import { ComponentType, ReactNode } from "react";
|
|
3
2
|
|
|
3
|
+
//#region src/route.d.ts
|
|
4
|
+
declare const routeDefinitionSymbol: unique symbol;
|
|
5
|
+
/**
|
|
6
|
+
* Arguments passed to loader functions.
|
|
7
|
+
*/
|
|
8
|
+
type LoaderArgs = {
|
|
9
|
+
/** Extracted path parameters */
|
|
10
|
+
params: Record<string, string>;
|
|
11
|
+
/** Request object with URL and headers */
|
|
12
|
+
request: Request;
|
|
13
|
+
/** AbortSignal for cancellation on navigation */
|
|
14
|
+
signal: AbortSignal;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Route definition created by the `route` helper function.
|
|
18
|
+
*/
|
|
19
|
+
interface OpaqueRouteDefinition {
|
|
20
|
+
[routeDefinitionSymbol]: never;
|
|
21
|
+
path: string;
|
|
22
|
+
children?: RouteDefinition[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Any route definition defined by user.
|
|
26
|
+
*/
|
|
27
|
+
type RouteDefinition = OpaqueRouteDefinition | {
|
|
28
|
+
path: string;
|
|
29
|
+
component?: ComponentType<{}>;
|
|
30
|
+
children?: RouteDefinition[];
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Route definition with loader - infers TData from loader return type.
|
|
34
|
+
*/
|
|
35
|
+
type RouteWithLoader<TData> = {
|
|
36
|
+
path: string;
|
|
37
|
+
loader: (args: LoaderArgs) => TData;
|
|
38
|
+
component: ComponentType<{
|
|
39
|
+
data: TData;
|
|
40
|
+
}>;
|
|
41
|
+
children?: RouteDefinition[];
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Route definition without loader.
|
|
45
|
+
*/
|
|
46
|
+
type RouteWithoutLoader = {
|
|
47
|
+
path: string;
|
|
48
|
+
component?: ComponentType;
|
|
49
|
+
children?: RouteDefinition[];
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Helper function for creating type-safe route definitions.
|
|
53
|
+
*
|
|
54
|
+
* When a loader is provided, TypeScript infers the return type and ensures
|
|
55
|
+
* the component accepts a `data` prop of that type.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* // Route with async loader
|
|
60
|
+
* route({
|
|
61
|
+
* path: "users/:userId",
|
|
62
|
+
* loader: async ({ params, signal }) => {
|
|
63
|
+
* const res = await fetch(`/api/users/${params.userId}`, { signal });
|
|
64
|
+
* return res.json() as Promise<User>;
|
|
65
|
+
* },
|
|
66
|
+
* component: UserDetail, // Must accept { data: Promise<User> }
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* // Route without loader
|
|
70
|
+
* route({
|
|
71
|
+
* path: "about",
|
|
72
|
+
* component: AboutPage, // No data prop required
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
declare function route<TData>(definition: RouteWithLoader<TData>): OpaqueRouteDefinition;
|
|
77
|
+
declare function route(definition: RouteWithoutLoader): OpaqueRouteDefinition;
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/Router.d.ts
|
|
80
|
+
type RouterProps = {
|
|
81
|
+
routes: RouteDefinition[];
|
|
82
|
+
};
|
|
83
|
+
declare function Router({
|
|
84
|
+
routes: inputRoutes
|
|
85
|
+
}: RouterProps): ReactNode;
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/Outlet.d.ts
|
|
88
|
+
/**
|
|
89
|
+
* Renders the matched child route.
|
|
90
|
+
* Used in layout components to specify where child routes should render.
|
|
91
|
+
*/
|
|
92
|
+
declare function Outlet(): ReactNode;
|
|
93
|
+
//#endregion
|
|
4
94
|
//#region src/types.d.ts
|
|
95
|
+
declare const InternalRouteDefinitionSymbol: unique symbol;
|
|
5
96
|
/**
|
|
6
97
|
* Route definition for the router.
|
|
98
|
+
* When a loader is defined, the component receives the loader result as a `data` prop.
|
|
7
99
|
*/
|
|
8
|
-
type
|
|
100
|
+
type InternalRouteDefinition = {
|
|
101
|
+
[InternalRouteDefinitionSymbol]: never;
|
|
9
102
|
/** Path pattern to match (e.g., "users/:id") */
|
|
10
103
|
path: string;
|
|
11
|
-
/** Component to render when this route matches */
|
|
12
|
-
component?: ComponentType;
|
|
13
104
|
/** Child routes for nested routing */
|
|
14
|
-
children?:
|
|
105
|
+
children?: InternalRouteDefinition[];
|
|
106
|
+
/** Data loader function for this route */
|
|
107
|
+
loader?: (args: LoaderArgs) => unknown;
|
|
108
|
+
/** Component to render when this route matches */
|
|
109
|
+
component?: ComponentType<{
|
|
110
|
+
data?: unknown;
|
|
111
|
+
}>;
|
|
15
112
|
};
|
|
16
113
|
/**
|
|
17
114
|
* A matched route with its parameters.
|
|
18
115
|
*/
|
|
19
116
|
type MatchedRoute = {
|
|
20
117
|
/** The original route definition */
|
|
21
|
-
route:
|
|
118
|
+
route: InternalRouteDefinition;
|
|
22
119
|
/** Extracted path parameters */
|
|
23
120
|
params: Record<string, string>;
|
|
24
121
|
/** The matched pathname segment */
|
|
25
122
|
pathname: string;
|
|
26
123
|
};
|
|
124
|
+
/**
|
|
125
|
+
* A matched route with loader data.
|
|
126
|
+
*/
|
|
127
|
+
type MatchedRouteWithData = MatchedRoute & {
|
|
128
|
+
/** Data returned from the loader (undefined if no loader) */
|
|
129
|
+
data: unknown | undefined;
|
|
130
|
+
};
|
|
27
131
|
/**
|
|
28
132
|
* Options for navigation.
|
|
29
133
|
*/
|
|
@@ -42,43 +146,6 @@ type Location = {
|
|
|
42
146
|
hash: string;
|
|
43
147
|
};
|
|
44
148
|
//#endregion
|
|
45
|
-
//#region src/Router.d.ts
|
|
46
|
-
type RouterProps = {
|
|
47
|
-
routes: RouteDefinition[];
|
|
48
|
-
children?: ReactNode;
|
|
49
|
-
};
|
|
50
|
-
declare function Router({
|
|
51
|
-
routes,
|
|
52
|
-
children
|
|
53
|
-
}: RouterProps): ReactNode;
|
|
54
|
-
//#endregion
|
|
55
|
-
//#region src/Link.d.ts
|
|
56
|
-
type LinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
|
|
57
|
-
/** The destination URL */
|
|
58
|
-
to: string;
|
|
59
|
-
/** Replace current history entry instead of pushing */
|
|
60
|
-
replace?: boolean;
|
|
61
|
-
/** State to associate with the navigation */
|
|
62
|
-
state?: unknown;
|
|
63
|
-
children?: ReactNode;
|
|
64
|
-
};
|
|
65
|
-
declare const Link: react0.ForwardRefExoticComponent<Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
|
|
66
|
-
/** The destination URL */
|
|
67
|
-
to: string;
|
|
68
|
-
/** Replace current history entry instead of pushing */
|
|
69
|
-
replace?: boolean;
|
|
70
|
-
/** State to associate with the navigation */
|
|
71
|
-
state?: unknown;
|
|
72
|
-
children?: ReactNode;
|
|
73
|
-
} & react0.RefAttributes<HTMLAnchorElement>>;
|
|
74
|
-
//#endregion
|
|
75
|
-
//#region src/Outlet.d.ts
|
|
76
|
-
/**
|
|
77
|
-
* Renders the matched child route.
|
|
78
|
-
* Used in layout components to specify where child routes should render.
|
|
79
|
-
*/
|
|
80
|
-
declare function Outlet(): ReactNode;
|
|
81
|
-
//#endregion
|
|
82
149
|
//#region src/hooks/useNavigate.d.ts
|
|
83
150
|
/**
|
|
84
151
|
* Returns a function for programmatic navigation.
|
|
@@ -104,5 +171,5 @@ type SetSearchParams = (params: URLSearchParams | Record<string, string> | ((pre
|
|
|
104
171
|
*/
|
|
105
172
|
declare function useSearchParams(): [URLSearchParams, SetSearchParams];
|
|
106
173
|
//#endregion
|
|
107
|
-
export {
|
|
174
|
+
export { type LoaderArgs, type Location, type MatchedRoute, type MatchedRouteWithData, type NavigateOptions, Outlet, type RouteDefinition, Router, type RouterProps, route, useLocation, useNavigate, useParams, useSearchParams };
|
|
108
175
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/route.ts","../src/Router.tsx","../src/Outlet.tsx","../src/types.ts","../src/hooks/useNavigate.ts","../src/hooks/useLocation.ts","../src/hooks/useParams.ts","../src/hooks/useSearchParams.ts"],"sourcesContent":[],"mappings":";;;cAEM;;AAFqC;AAO3C;AAEU,KAFE,UAAA,GAEF;EAEC;EAED,MAAA,EAJA,MAIA,CAAA,MAAA,EAAA,MAAA,CAAA;EAAW;EAMJ,OAAA,EARN,OAQM;EASL;EACR,MAAA,EAhBM,WAgBN;CAGc;;;AAEZ;AAOW,UAtBA,qBAAA,CAsBA;EAAe,CArB7B,qBAAA,CAqB6B,EAAA,KAAA;EACG,IAAA,EAAA,MAAA;EAAtB,QAAA,CAAA,EApBA,eAoBA,EAAA;;;AAEe;AAsC5B;AAC8B,KAvDlB,eAAA,GACR,qBAsD0B,GAAA;EAAhB,IAAA,EAAA,MAAA;EACX,SAAA,CAAA,EApDe,aAoDf,CAAA,CAAA,CAAA,CAAA;EAAqB,QAAA,CAAA,EAnDP,eAmDO,EAAA;AACxB,CAAA;;;;AC3DA,KDaK,eCbkB,CAAA,KAAA,CAAA,GACb;EAGM,IAAA,EAAA,MAAM;EAAW,MAAA,EAAA,CAAA,IAAA,EDWhB,UCXgB,EAAA,GDWD,KCXC;EAAe,SAAA,EDYnC,aCZmC,CAAA;IAAc,IAAA,EDY3B,KCZ2B;EAAS,CAAA,CAAA;aDc1D;;;AErCb;;KF2CK,kBAAA;;EG/CC,SAAA,CAAA,EHiDQ,aGjDR;EAMM,QAAA,CAAA,EH6CC,eG7CsB,EAAA;CAChC;;;;;AA+BH;AAYA;AAQA;AAUA;;;;AChEA;;;;ACAA;;;;ACDA;;;;;;iBN4EgB,yBACF,gBAAgB,SAC3B;iBACa,KAAA,aAAkB,qBAAqB;;;KC3D3C,WAAA;EDxBN,MAAA,ECyBI,eDzBJ,EAAgC;AAKtC,CAAA;AAEU,iBCqBM,MAAA,CDrBN;EAAA,MAAA,ECqBuB;ADrBvB,CAAA,ECqBsC,WDrBtC,CAAA,ECqBoD,SDrBpD;;;;;AATiC;AAO3C;AAEU,iBEFM,MAAA,CAAA,CFEN,EEFgB,SFEhB;;;cGNJ;AHHqC;AAO3C;;;AAMU,KGJE,uBAAA,GHIF;EAAW,CGHlB,6BAAA,CHGkB,EAAA,KAAA;EAMJ;EASL,IAAA,EAAA,MAAA;EACR;EAGc,QAAA,CAAA,EGlBL,uBHkBK,EAAA;EACD;EAAe,MAAA,CAAA,EAAA,CAAA,IAAA,EGbd,UHac,EAAA,GAAA,OAAA;EAM3B;EAEY,SAAA,CAAA,EGnBH,aHmBG,CAAA;IAAe,IAAA,CAAA,EAAA,OAAA;EACG,CAAA,CAAA;CAAtB;AA2Cb;;;KG5CY,YAAA;EFfA;EAII,KAAA,EEaP,uBFba;EAAW;EAAe,MAAA,EEetC,MFfsC,CAAA,MAAA,EAAA,MAAA,CAAA;EAAc;EAAS,QAAA,EAAA,MAAA;;;;ACvBvE;KC8CY,oBAAA,GAAuB;;;AApD2B,CAAA;AAQ9D;;;AAWkB,KAyCN,eAAA,GAzCM;EAEJ;EAAa,OAAA,CAAA,EAAA,OAAA;EAmBf;EAYA,KAAA,CAAA,EAAA,OAAA;AAQZ,CAAA;AAUA;;;KAAY,QAAA;EChEI,QAAA,EAAA,MAAW;;;;;;;;AJPgB;AAO/B,iBIAI,WAAA,CAAA,CJAM,EAAA,CAAA,EAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EIAgC,eJAhC,EAAA,GAAA,IAAA;;;;;AAPqB;AAO/B,iBKAI,WAAA,CAAA,CLAM,EKAS,QLAT;;;;;;AALhB,iBMIU,SNJsB,CAAA,UMK1B,MNL0B,CAAA,MAAA,EAAA,MAAA,CAAA,GMKD,MNLC,CAAA,MAAA,EAAA,MAAA,CAAA,CAAA,CAAA,CAAA,EMMjC,CNNiC;;;KOCjC,eAAA,YAEC,kBACA,iCACQ,oBAAoB,kBAAkB;;;APPT;AAO/B,iBOMI,eAAA,CAAA,CPNM,EAAA,COMc,ePNd,EOM+B,ePN/B,CAAA"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createContext,
|
|
2
|
-
import { jsx
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
|
|
4
4
|
//#region src/context/RouterContext.ts
|
|
5
5
|
const RouterContext = createContext(null);
|
|
@@ -8,6 +8,19 @@ const RouterContext = createContext(null);
|
|
|
8
8
|
//#region src/context/RouteContext.ts
|
|
9
9
|
const RouteContext = createContext(null);
|
|
10
10
|
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/types.ts
|
|
13
|
+
/**
|
|
14
|
+
* Converts user-defined routes to internal route definitions.
|
|
15
|
+
* This function is used internally by the Router.
|
|
16
|
+
*
|
|
17
|
+
* Actually, this function just performs a type assertion since
|
|
18
|
+
* both RouteDefinition and InternalRouteDefinition have the same runtime shape.
|
|
19
|
+
*/
|
|
20
|
+
function internalRoutes(routes) {
|
|
21
|
+
return routes;
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
//#endregion
|
|
12
25
|
//#region src/core/matchRoutes.ts
|
|
13
26
|
/**
|
|
@@ -15,8 +28,8 @@ const RouteContext = createContext(null);
|
|
|
15
28
|
* Returns null if no match is found.
|
|
16
29
|
*/
|
|
17
30
|
function matchRoutes(routes, pathname) {
|
|
18
|
-
for (const route of routes) {
|
|
19
|
-
const matched = matchRoute(route, pathname);
|
|
31
|
+
for (const route$1 of routes) {
|
|
32
|
+
const matched = matchRoute(route$1, pathname);
|
|
20
33
|
if (matched) return matched;
|
|
21
34
|
}
|
|
22
35
|
return null;
|
|
@@ -24,12 +37,12 @@ function matchRoutes(routes, pathname) {
|
|
|
24
37
|
/**
|
|
25
38
|
* Match a single route and its children recursively.
|
|
26
39
|
*/
|
|
27
|
-
function matchRoute(route, pathname) {
|
|
28
|
-
const hasChildren = Boolean(route.children?.length);
|
|
29
|
-
const { matched, params, consumedPathname } = matchPath(route.path, pathname, !hasChildren);
|
|
40
|
+
function matchRoute(route$1, pathname) {
|
|
41
|
+
const hasChildren = Boolean(route$1.children?.length);
|
|
42
|
+
const { matched, params, consumedPathname } = matchPath(route$1.path, pathname, !hasChildren);
|
|
30
43
|
if (!matched) return null;
|
|
31
44
|
const result = {
|
|
32
|
-
route,
|
|
45
|
+
route: route$1,
|
|
33
46
|
params,
|
|
34
47
|
pathname: consumedPathname
|
|
35
48
|
};
|
|
@@ -37,7 +50,7 @@ function matchRoute(route, pathname) {
|
|
|
37
50
|
let remainingPathname = pathname.slice(consumedPathname.length);
|
|
38
51
|
if (!remainingPathname.startsWith("/")) remainingPathname = "/" + remainingPathname;
|
|
39
52
|
if (remainingPathname === "") remainingPathname = "/";
|
|
40
|
-
for (const child of route.children) {
|
|
53
|
+
for (const child of route$1.children) {
|
|
41
54
|
const childMatch = matchRoute(child, remainingPathname);
|
|
42
55
|
if (childMatch) return [result, ...childMatch.map((m) => ({
|
|
43
56
|
...m,
|
|
@@ -47,7 +60,7 @@ function matchRoute(route, pathname) {
|
|
|
47
60
|
}
|
|
48
61
|
}))];
|
|
49
62
|
}
|
|
50
|
-
if (route.component) return [result];
|
|
63
|
+
if (route$1.component) return [result];
|
|
51
64
|
return null;
|
|
52
65
|
}
|
|
53
66
|
return [result];
|
|
@@ -84,7 +97,63 @@ function matchPath(pattern, pathname, exact) {
|
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
//#endregion
|
|
87
|
-
//#region src/
|
|
100
|
+
//#region src/core/loaderCache.ts
|
|
101
|
+
/**
|
|
102
|
+
* Cache for loader results.
|
|
103
|
+
* Key format: `${entryId}:${routePath}`
|
|
104
|
+
*/
|
|
105
|
+
const loaderCache = /* @__PURE__ */ new Map();
|
|
106
|
+
/**
|
|
107
|
+
* Get or create a loader result from cache.
|
|
108
|
+
* If the result is not cached, executes the loader and caches the result.
|
|
109
|
+
*/
|
|
110
|
+
function getOrCreateLoaderResult(entryId, route$1, args) {
|
|
111
|
+
if (!route$1.loader) return;
|
|
112
|
+
const cacheKey = `${entryId}:${route$1.path}`;
|
|
113
|
+
if (!loaderCache.has(cacheKey)) loaderCache.set(cacheKey, route$1.loader(args));
|
|
114
|
+
return loaderCache.get(cacheKey);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Create a Request object for loader args.
|
|
118
|
+
*/
|
|
119
|
+
function createLoaderRequest(url) {
|
|
120
|
+
return new Request(url.href, { method: "GET" });
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Execute loaders for matched routes and return routes with data.
|
|
124
|
+
* Results are cached by navigation entry id to prevent duplicate execution.
|
|
125
|
+
*/
|
|
126
|
+
function executeLoaders(matchedRoutes, entryId, request, signal) {
|
|
127
|
+
return matchedRoutes.map((match) => {
|
|
128
|
+
const { route: route$1, params } = match;
|
|
129
|
+
const data = getOrCreateLoaderResult(entryId, route$1, {
|
|
130
|
+
params,
|
|
131
|
+
request,
|
|
132
|
+
signal
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
...match,
|
|
136
|
+
data
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/core/navigation.ts
|
|
143
|
+
/**
|
|
144
|
+
* Fallback AbortController for data loading initialized outside of a navigation event.
|
|
145
|
+
* Aborted when the next navigation occurs.
|
|
146
|
+
*
|
|
147
|
+
* To save resources, this controller is created only when needed.
|
|
148
|
+
*/
|
|
149
|
+
let idleController = null;
|
|
150
|
+
/**
|
|
151
|
+
* Get the current abort signal for loader cancellation.
|
|
152
|
+
*/
|
|
153
|
+
function getIdleAbortSignal() {
|
|
154
|
+
idleController ??= new AbortController();
|
|
155
|
+
return idleController.signal;
|
|
156
|
+
}
|
|
88
157
|
/**
|
|
89
158
|
* Check if Navigation API is available.
|
|
90
159
|
*/
|
|
@@ -93,6 +162,7 @@ function hasNavigation() {
|
|
|
93
162
|
}
|
|
94
163
|
/**
|
|
95
164
|
* Subscribe to Navigation API's currententrychange event.
|
|
165
|
+
* Returns a cleanup function.
|
|
96
166
|
*/
|
|
97
167
|
function subscribeToNavigation(callback) {
|
|
98
168
|
if (!hasNavigation()) return () => {};
|
|
@@ -114,42 +184,83 @@ function getNavigationSnapshot() {
|
|
|
114
184
|
function getServerSnapshot() {
|
|
115
185
|
return null;
|
|
116
186
|
}
|
|
117
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Set up navigation interception for client-side routing.
|
|
189
|
+
* Returns a cleanup function.
|
|
190
|
+
*/
|
|
191
|
+
function setupNavigationInterception(routes) {
|
|
192
|
+
if (!hasNavigation()) return () => {};
|
|
193
|
+
const handleNavigate = (event) => {
|
|
194
|
+
if (!event.canIntercept || event.hashChange) return;
|
|
195
|
+
const url = new URL(event.destination.url);
|
|
196
|
+
const matched = matchRoutes(routes, url.pathname);
|
|
197
|
+
if (matched) {
|
|
198
|
+
if (idleController) {
|
|
199
|
+
idleController.abort();
|
|
200
|
+
idleController = null;
|
|
201
|
+
}
|
|
202
|
+
event.intercept({ handler: async () => {
|
|
203
|
+
const request = createLoaderRequest(url);
|
|
204
|
+
const currentEntry = navigation.currentEntry;
|
|
205
|
+
if (!currentEntry) throw new Error("Navigation currentEntry is null during navigation interception");
|
|
206
|
+
const results = executeLoaders(matched, currentEntry.id, request, event.signal);
|
|
207
|
+
await Promise.all(results.map((r) => r.data));
|
|
208
|
+
} });
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
navigation.addEventListener("navigate", handleNavigate);
|
|
212
|
+
return () => {
|
|
213
|
+
if (hasNavigation()) navigation.removeEventListener("navigate", handleNavigate);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Navigate to a new URL programmatically.
|
|
218
|
+
*/
|
|
219
|
+
function performNavigation(to, options) {
|
|
220
|
+
if (!hasNavigation()) return;
|
|
221
|
+
navigation.navigate(to, {
|
|
222
|
+
history: options?.replace ? "replace" : "push",
|
|
223
|
+
state: options?.state
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/Router.tsx
|
|
229
|
+
function Router({ routes: inputRoutes }) {
|
|
230
|
+
const routes = internalRoutes(inputRoutes);
|
|
118
231
|
const currentEntry = useSyncExternalStore(subscribeToNavigation, getNavigationSnapshot, getServerSnapshot);
|
|
119
232
|
useEffect(() => {
|
|
120
|
-
|
|
121
|
-
const handleNavigate = (event) => {
|
|
122
|
-
if (!event.canIntercept || event.hashChange) return;
|
|
123
|
-
if (matchRoutes(routes, new URL(event.destination.url).pathname)) event.intercept({ handler: async () => {} });
|
|
124
|
-
};
|
|
125
|
-
navigation.addEventListener("navigate", handleNavigate);
|
|
126
|
-
return () => {
|
|
127
|
-
if (hasNavigation()) navigation.removeEventListener("navigate", handleNavigate);
|
|
128
|
-
};
|
|
233
|
+
return setupNavigationInterception(routes);
|
|
129
234
|
}, [routes]);
|
|
130
|
-
const navigate = useCallback((to, options) =>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
235
|
+
const navigate = useCallback((to, options) => performNavigation(to, options), []);
|
|
236
|
+
return useMemo(() => {
|
|
237
|
+
if (currentEntry === null) return null;
|
|
238
|
+
const currentUrl = currentEntry.url;
|
|
239
|
+
if (currentUrl === null) return null;
|
|
240
|
+
const url = new URL(currentUrl);
|
|
241
|
+
const currentEntryId = currentEntry.id;
|
|
242
|
+
const matchedRoutesWithData = (() => {
|
|
243
|
+
const matched = matchRoutes(routes, url.pathname);
|
|
244
|
+
if (!matched) return null;
|
|
245
|
+
return executeLoaders(matched, currentEntryId, createLoaderRequest(url), getIdleAbortSignal());
|
|
246
|
+
})();
|
|
247
|
+
const routerContextValue = {
|
|
248
|
+
currentEntry,
|
|
249
|
+
url,
|
|
250
|
+
navigate
|
|
251
|
+
};
|
|
252
|
+
return /* @__PURE__ */ jsx(RouterContext.Provider, {
|
|
253
|
+
value: routerContextValue,
|
|
254
|
+
children: matchedRoutesWithData ? /* @__PURE__ */ jsx(RouteRenderer, {
|
|
255
|
+
matchedRoutes: matchedRoutesWithData,
|
|
256
|
+
index: 0
|
|
257
|
+
}) : null
|
|
135
258
|
});
|
|
136
|
-
}, [
|
|
137
|
-
|
|
138
|
-
const matchedRoutes = useMemo(() => {
|
|
139
|
-
if (!currentUrl) return null;
|
|
140
|
-
return matchRoutes(routes, new URL(currentUrl).pathname);
|
|
141
|
-
}, [currentUrl, routes]);
|
|
142
|
-
const routerContextValue = useMemo(() => ({
|
|
259
|
+
}, [
|
|
260
|
+
navigate,
|
|
143
261
|
currentEntry,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return /* @__PURE__ */ jsxs(RouterContext.Provider, {
|
|
147
|
-
value: routerContextValue,
|
|
148
|
-
children: [matchedRoutes ? /* @__PURE__ */ jsx(RouteRenderer, {
|
|
149
|
-
matchedRoutes,
|
|
150
|
-
index: 0
|
|
151
|
-
}) : null, children]
|
|
152
|
-
});
|
|
262
|
+
routes
|
|
263
|
+
]);
|
|
153
264
|
}
|
|
154
265
|
/**
|
|
155
266
|
* Recursively render matched routes with proper context.
|
|
@@ -157,8 +268,8 @@ function Router({ routes, children }) {
|
|
|
157
268
|
function RouteRenderer({ matchedRoutes, index }) {
|
|
158
269
|
const match = matchedRoutes[index];
|
|
159
270
|
if (!match) return null;
|
|
160
|
-
const { route, params, pathname } = match;
|
|
161
|
-
const Component = route.component;
|
|
271
|
+
const { route: route$1, params, pathname, data } = match;
|
|
272
|
+
const Component = route$1.component;
|
|
162
273
|
const outlet = index < matchedRoutes.length - 1 ? /* @__PURE__ */ jsx(RouteRenderer, {
|
|
163
274
|
matchedRoutes,
|
|
164
275
|
index: index + 1
|
|
@@ -172,52 +283,17 @@ function RouteRenderer({ matchedRoutes, index }) {
|
|
|
172
283
|
pathname,
|
|
173
284
|
outlet
|
|
174
285
|
]);
|
|
286
|
+
const renderComponent = () => {
|
|
287
|
+
if (!Component) return outlet;
|
|
288
|
+
if (route$1.loader) return /* @__PURE__ */ jsx(Component, { data });
|
|
289
|
+
return /* @__PURE__ */ jsx(Component, {});
|
|
290
|
+
};
|
|
175
291
|
return /* @__PURE__ */ jsx(RouteContext.Provider, {
|
|
176
292
|
value: routeContextValue,
|
|
177
|
-
children:
|
|
293
|
+
children: renderComponent()
|
|
178
294
|
});
|
|
179
295
|
}
|
|
180
296
|
|
|
181
|
-
//#endregion
|
|
182
|
-
//#region src/hooks/useNavigate.ts
|
|
183
|
-
/**
|
|
184
|
-
* Returns a function for programmatic navigation.
|
|
185
|
-
*/
|
|
186
|
-
function useNavigate() {
|
|
187
|
-
const context = useContext(RouterContext);
|
|
188
|
-
if (!context) throw new Error("useNavigate must be used within a Router");
|
|
189
|
-
return context.navigate;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
//#endregion
|
|
193
|
-
//#region src/Link.tsx
|
|
194
|
-
const Link = forwardRef(function Link$1({ to, replace, state, onClick, children, ...rest }, ref) {
|
|
195
|
-
const navigate = useNavigate();
|
|
196
|
-
return /* @__PURE__ */ jsx("a", {
|
|
197
|
-
ref,
|
|
198
|
-
href: to,
|
|
199
|
-
onClick: useCallback((event) => {
|
|
200
|
-
onClick?.(event);
|
|
201
|
-
if (event.defaultPrevented) return;
|
|
202
|
-
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
|
|
203
|
-
if (event.button !== 0) return;
|
|
204
|
-
event.preventDefault();
|
|
205
|
-
navigate(to, {
|
|
206
|
-
replace,
|
|
207
|
-
state
|
|
208
|
-
});
|
|
209
|
-
}, [
|
|
210
|
-
navigate,
|
|
211
|
-
to,
|
|
212
|
-
replace,
|
|
213
|
-
state,
|
|
214
|
-
onClick
|
|
215
|
-
]),
|
|
216
|
-
...rest,
|
|
217
|
-
children
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
297
|
//#endregion
|
|
222
298
|
//#region src/Outlet.tsx
|
|
223
299
|
/**
|
|
@@ -230,6 +306,17 @@ function Outlet() {
|
|
|
230
306
|
return routeContext.outlet;
|
|
231
307
|
}
|
|
232
308
|
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/hooks/useNavigate.ts
|
|
311
|
+
/**
|
|
312
|
+
* Returns a function for programmatic navigation.
|
|
313
|
+
*/
|
|
314
|
+
function useNavigate() {
|
|
315
|
+
const context = useContext(RouterContext);
|
|
316
|
+
if (!context) throw new Error("useNavigate must be used within a Router");
|
|
317
|
+
return context.navigate;
|
|
318
|
+
}
|
|
319
|
+
|
|
233
320
|
//#endregion
|
|
234
321
|
//#region src/hooks/useLocation.ts
|
|
235
322
|
/**
|
|
@@ -238,19 +325,14 @@ function Outlet() {
|
|
|
238
325
|
function useLocation() {
|
|
239
326
|
const context = useContext(RouterContext);
|
|
240
327
|
if (!context) throw new Error("useLocation must be used within a Router");
|
|
328
|
+
const { url } = context;
|
|
241
329
|
return useMemo(() => {
|
|
242
|
-
if (!context.currentEntry?.url) return {
|
|
243
|
-
pathname: "/",
|
|
244
|
-
search: "",
|
|
245
|
-
hash: ""
|
|
246
|
-
};
|
|
247
|
-
const url = new URL(context.currentEntry.url);
|
|
248
330
|
return {
|
|
249
331
|
pathname: url.pathname,
|
|
250
332
|
search: url.search,
|
|
251
333
|
hash: url.hash
|
|
252
334
|
};
|
|
253
|
-
}, [
|
|
335
|
+
}, [url]);
|
|
254
336
|
}
|
|
255
337
|
|
|
256
338
|
//#endregion
|
|
@@ -272,13 +354,8 @@ function useParams() {
|
|
|
272
354
|
function useSearchParams() {
|
|
273
355
|
const context = useContext(RouterContext);
|
|
274
356
|
if (!context) throw new Error("useSearchParams must be used within a Router");
|
|
275
|
-
return [
|
|
276
|
-
|
|
277
|
-
return new URL(context.currentEntry.url).searchParams;
|
|
278
|
-
}, [context.currentEntry?.url]), useCallback((params) => {
|
|
279
|
-
const currentUrl = context.currentEntry?.url;
|
|
280
|
-
if (!currentUrl) return;
|
|
281
|
-
const url = new URL(currentUrl);
|
|
357
|
+
return [context.url.searchParams, useCallback((params) => {
|
|
358
|
+
const url = new URL(context.url);
|
|
282
359
|
let newParams;
|
|
283
360
|
if (typeof params === "function") {
|
|
284
361
|
const result = params(new URLSearchParams(url.search));
|
|
@@ -291,5 +368,11 @@ function useSearchParams() {
|
|
|
291
368
|
}
|
|
292
369
|
|
|
293
370
|
//#endregion
|
|
294
|
-
|
|
371
|
+
//#region src/route.ts
|
|
372
|
+
function route(definition) {
|
|
373
|
+
return definition;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
//#endregion
|
|
377
|
+
export { Outlet, Router, route, useLocation, useNavigate, useParams, useSearchParams };
|
|
295
378
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["result: MatchedRoute","urlPatternPath: string","params: Record<string, string>","consumedPathname: string","Link","newParams: URLSearchParams"],"sources":["../src/context/RouterContext.ts","../src/context/RouteContext.ts","../src/core/matchRoutes.ts","../src/Router.tsx","../src/hooks/useNavigate.ts","../src/Link.tsx","../src/Outlet.tsx","../src/hooks/useLocation.ts","../src/hooks/useParams.ts","../src/hooks/useSearchParams.ts"],"sourcesContent":["import { createContext } from \"react\";\nimport type { NavigateOptions } from \"../types.js\";\n\nexport type RouterContextValue = {\n /** Current navigation entry */\n currentEntry: NavigationHistoryEntry | null;\n /** Navigate to a new URL */\n navigate: (to: string, options?: NavigateOptions) => void;\n};\n\nexport const RouterContext = createContext<RouterContextValue | null>(null);\n","import { createContext, type ReactNode } from \"react\";\n\nexport type RouteContextValue = {\n /** Matched route parameters */\n params: Record<string, string>;\n /** The matched path pattern */\n matchedPath: string;\n /** Child route element to render via Outlet */\n outlet: ReactNode;\n};\n\nexport const RouteContext = createContext<RouteContextValue | null>(null);\n","import type { RouteDefinition, MatchedRoute } from \"../types.js\";\n\n/**\n * Match a pathname against a route tree, returning the matched route stack.\n * Returns null if no match is found.\n */\nexport function matchRoutes(\n routes: RouteDefinition[],\n pathname: string,\n): MatchedRoute[] | null {\n for (const route of routes) {\n const matched = matchRoute(route, pathname);\n if (matched) {\n return matched;\n }\n }\n return null;\n}\n\n/**\n * Match a single route and its children recursively.\n */\nfunction matchRoute(\n route: RouteDefinition,\n pathname: string,\n): MatchedRoute[] | null {\n const hasChildren = Boolean(route.children?.length);\n\n // For parent routes (with children), we need to match as a prefix\n // For leaf routes (no children), we need an exact match\n const { matched, params, consumedPathname } = matchPath(\n route.path,\n pathname,\n !hasChildren,\n );\n\n if (!matched) {\n return null;\n }\n\n const result: MatchedRoute = {\n route,\n params,\n pathname: consumedPathname,\n };\n\n // If this route has children, try to match them\n if (hasChildren) {\n // Calculate remaining pathname, ensuring it starts with /\n let remainingPathname = pathname.slice(consumedPathname.length);\n if (!remainingPathname.startsWith(\"/\")) {\n remainingPathname = \"/\" + remainingPathname;\n }\n if (remainingPathname === \"\") {\n remainingPathname = \"/\";\n }\n\n for (const child of route.children!) {\n const childMatch = matchRoute(child, remainingPathname);\n if (childMatch) {\n // Merge params from parent into children\n return [\n result,\n ...childMatch.map((m) => ({\n ...m,\n params: { ...params, ...m.params },\n })),\n ];\n }\n }\n\n // If no children matched but this route has a component, it's still a valid match\n if (route.component) {\n return [result];\n }\n\n return null;\n }\n\n return [result];\n}\n\n/**\n * Match a path pattern against a pathname.\n */\nfunction matchPath(\n pattern: string,\n pathname: string,\n exact: boolean,\n): {\n matched: boolean;\n params: Record<string, string>;\n consumedPathname: string;\n} {\n // Normalize pattern\n const normalizedPattern = pattern.startsWith(\"/\") ? pattern : `/${pattern}`;\n\n // Build URLPattern\n let urlPatternPath: string;\n if (exact) {\n urlPatternPath = normalizedPattern;\n } else if (normalizedPattern === \"/\") {\n // Special case: root path as prefix matches anything\n urlPatternPath = \"/*\";\n } else {\n // For other prefix matches, add optional wildcard suffix\n urlPatternPath = `${normalizedPattern}{/*}?`;\n }\n\n const urlPattern = new URLPattern({ pathname: urlPatternPath });\n\n const match = urlPattern.exec({ pathname });\n if (!match) {\n return { matched: false, params: {}, consumedPathname: \"\" };\n }\n\n // Extract params (excluding the wildcard group \"0\")\n const params: Record<string, string> = {};\n for (const [key, value] of Object.entries(match.pathname.groups)) {\n if (value !== undefined && key !== \"0\") {\n params[key] = value;\n }\n }\n\n // Calculate consumed pathname\n let consumedPathname: string;\n if (exact) {\n consumedPathname = pathname;\n } else if (normalizedPattern === \"/\") {\n // Root pattern consumes just \"/\"\n consumedPathname = \"/\";\n } else {\n // For prefix matches, calculate based on pattern segments\n const patternSegments = normalizedPattern.split(\"/\").filter(Boolean);\n const pathnameSegments = pathname.split(\"/\").filter(Boolean);\n consumedPathname =\n \"/\" + pathnameSegments.slice(0, patternSegments.length).join(\"/\");\n }\n\n return { matched: true, params, consumedPathname };\n}\n","import {\n type ReactNode,\n useCallback,\n useEffect,\n useMemo,\n useSyncExternalStore,\n} from \"react\";\nimport { RouterContext } from \"./context/RouterContext.js\";\nimport { RouteContext } from \"./context/RouteContext.js\";\nimport type {\n RouteDefinition,\n NavigateOptions,\n MatchedRoute,\n} from \"./types.js\";\nimport { matchRoutes } from \"./core/matchRoutes.js\";\n\nexport type RouterProps = {\n routes: RouteDefinition[];\n children?: ReactNode;\n};\n\n/**\n * Check if Navigation API is available.\n */\nfunction hasNavigation(): boolean {\n return typeof navigation !== \"undefined\";\n}\n\n/**\n * Subscribe to Navigation API's currententrychange event.\n */\nfunction subscribeToNavigation(callback: () => void): () => void {\n if (!hasNavigation()) {\n return () => {};\n }\n navigation.addEventListener(\"currententrychange\", callback);\n return () => {\n if (hasNavigation()) {\n navigation.removeEventListener(\"currententrychange\", callback);\n }\n };\n}\n\n/**\n * Get current navigation entry snapshot.\n */\nfunction getNavigationSnapshot(): NavigationHistoryEntry | null {\n if (!hasNavigation()) {\n return null;\n }\n return navigation.currentEntry;\n}\n\n/**\n * Server snapshot - Navigation API not available on server.\n */\nfunction getServerSnapshot(): null {\n return null;\n}\n\nexport function Router({ routes, children }: RouterProps): ReactNode {\n const currentEntry = useSyncExternalStore(\n subscribeToNavigation,\n getNavigationSnapshot,\n getServerSnapshot,\n );\n\n // Set up navigation interception\n useEffect(() => {\n if (!hasNavigation()) {\n return;\n }\n\n const handleNavigate = (event: NavigateEvent) => {\n // Only intercept same-origin navigations\n if (!event.canIntercept || event.hashChange) {\n return;\n }\n\n // Check if the URL matches any of our routes\n const url = new URL(event.destination.url);\n const matched = matchRoutes(routes, url.pathname);\n\n if (matched) {\n event.intercept({\n handler: async () => {\n // Navigation will complete and currententrychange will fire\n },\n });\n }\n };\n\n navigation.addEventListener(\"navigate\", handleNavigate);\n return () => {\n if (hasNavigation()) {\n navigation.removeEventListener(\"navigate\", handleNavigate);\n }\n };\n }, [routes]);\n\n // Navigate function for programmatic navigation\n const navigate = useCallback((to: string, options?: NavigateOptions) => {\n if (!hasNavigation()) {\n return;\n }\n navigation.navigate(to, {\n history: options?.replace ? \"replace\" : \"push\",\n state: options?.state,\n });\n }, []);\n\n // Match current URL against routes\n const currentUrl = currentEntry?.url;\n const matchedRoutes = useMemo(() => {\n if (!currentUrl) return null;\n const url = new URL(currentUrl);\n return matchRoutes(routes, url.pathname);\n }, [currentUrl, routes]);\n\n const routerContextValue = useMemo(\n () => ({ currentEntry, navigate }),\n [currentEntry, navigate],\n );\n\n return (\n <RouterContext.Provider value={routerContextValue}>\n {matchedRoutes ? (\n <RouteRenderer matchedRoutes={matchedRoutes} index={0} />\n ) : null}\n {children}\n </RouterContext.Provider>\n );\n}\n\ntype RouteRendererProps = {\n matchedRoutes: MatchedRoute[];\n index: number;\n};\n\n/**\n * Recursively render matched routes with proper context.\n */\nfunction RouteRenderer({\n matchedRoutes,\n index,\n}: RouteRendererProps): ReactNode {\n const match = matchedRoutes[index];\n if (!match) return null;\n\n const { route, params, pathname } = match;\n const Component = route.component;\n\n // Create outlet for child routes\n const outlet =\n index < matchedRoutes.length - 1 ? (\n <RouteRenderer matchedRoutes={matchedRoutes} index={index + 1} />\n ) : null;\n\n const routeContextValue = useMemo(\n () => ({ params, matchedPath: pathname, outlet }),\n [params, pathname, outlet],\n );\n\n return (\n <RouteContext.Provider value={routeContextValue}>\n {Component ? <Component /> : outlet}\n </RouteContext.Provider>\n );\n}\n","import { useContext } from \"react\";\nimport { RouterContext } from \"../context/RouterContext.js\";\nimport type { NavigateOptions } from \"../types.js\";\n\n/**\n * Returns a function for programmatic navigation.\n */\nexport function useNavigate(): (to: string, options?: NavigateOptions) => void {\n const context = useContext(RouterContext);\n\n if (!context) {\n throw new Error(\"useNavigate must be used within a Router\");\n }\n\n return context.navigate;\n}\n","import {\n type AnchorHTMLAttributes,\n type MouseEvent,\n type ReactNode,\n forwardRef,\n useCallback,\n} from \"react\";\nimport { useNavigate } from \"./hooks/useNavigate.js\";\n\nexport type LinkProps = Omit<\n AnchorHTMLAttributes<HTMLAnchorElement>,\n \"href\"\n> & {\n /** The destination URL */\n to: string;\n /** Replace current history entry instead of pushing */\n replace?: boolean;\n /** State to associate with the navigation */\n state?: unknown;\n children?: ReactNode;\n};\n\nexport const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(\n { to, replace, state, onClick, children, ...rest },\n ref,\n) {\n const navigate = useNavigate();\n\n const handleClick = useCallback(\n (event: MouseEvent<HTMLAnchorElement>) => {\n // Call user's onClick handler if provided\n onClick?.(event);\n\n // Don't handle if default was prevented\n if (event.defaultPrevented) return;\n\n // Don't handle modified clicks (new tab, etc.)\n if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) {\n return;\n }\n\n // Don't handle right clicks\n if (event.button !== 0) return;\n\n // Prevent default and navigate via Navigation API\n event.preventDefault();\n navigate(to, { replace, state });\n },\n [navigate, to, replace, state, onClick],\n );\n\n return (\n <a ref={ref} href={to} onClick={handleClick} {...rest}>\n {children}\n </a>\n );\n});\n","import { type ReactNode, useContext } from \"react\";\nimport { RouteContext } from \"./context/RouteContext.js\";\n\n/**\n * Renders the matched child route.\n * Used in layout components to specify where child routes should render.\n */\nexport function Outlet(): ReactNode {\n const routeContext = useContext(RouteContext);\n\n if (!routeContext) {\n return null;\n }\n\n return routeContext.outlet;\n}\n","import { useContext, useMemo } from \"react\";\nimport { RouterContext } from \"../context/RouterContext.js\";\nimport type { Location } from \"../types.js\";\n\n/**\n * Returns the current location object.\n */\nexport function useLocation(): Location {\n const context = useContext(RouterContext);\n\n if (!context) {\n throw new Error(\"useLocation must be used within a Router\");\n }\n\n return useMemo(() => {\n if (!context.currentEntry?.url) {\n return { pathname: \"/\", search: \"\", hash: \"\" };\n }\n\n const url = new URL(context.currentEntry.url);\n return {\n pathname: url.pathname,\n search: url.search,\n hash: url.hash,\n };\n }, [context.currentEntry?.url]);\n}\n","import { useContext } from \"react\";\nimport { RouteContext } from \"../context/RouteContext.js\";\n\n/**\n * Returns route parameters from the matched path.\n */\nexport function useParams<\n T extends Record<string, string> = Record<string, string>,\n>(): T {\n const context = useContext(RouteContext);\n\n if (!context) {\n throw new Error(\"useParams must be used within a Router\");\n }\n\n return context.params as T;\n}\n","import { useCallback, useContext, useMemo } from \"react\";\nimport { RouterContext } from \"../context/RouterContext.js\";\n\ntype SetSearchParams = (\n params:\n | URLSearchParams\n | Record<string, string>\n | ((prev: URLSearchParams) => URLSearchParams | Record<string, string>),\n) => void;\n\n/**\n * Returns and allows manipulation of URL search parameters.\n */\nexport function useSearchParams(): [URLSearchParams, SetSearchParams] {\n const context = useContext(RouterContext);\n\n if (!context) {\n throw new Error(\"useSearchParams must be used within a Router\");\n }\n\n const searchParams = useMemo(() => {\n if (!context.currentEntry?.url) {\n return new URLSearchParams();\n }\n const url = new URL(context.currentEntry.url);\n return url.searchParams;\n }, [context.currentEntry?.url]);\n\n const setSearchParams = useCallback<SetSearchParams>(\n (params) => {\n const currentUrl = context.currentEntry?.url;\n if (!currentUrl) return;\n\n const url = new URL(currentUrl);\n\n let newParams: URLSearchParams;\n if (typeof params === \"function\") {\n const result = params(new URLSearchParams(url.search));\n newParams =\n result instanceof URLSearchParams\n ? result\n : new URLSearchParams(result);\n } else if (params instanceof URLSearchParams) {\n newParams = params;\n } else {\n newParams = new URLSearchParams(params);\n }\n\n url.search = newParams.toString();\n context.navigate(url.pathname + url.search + url.hash, { replace: true });\n },\n [context],\n );\n\n return [searchParams, setSearchParams];\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["route","result: MatchedRoute","urlPatternPath: string","params: Record<string, string>","consumedPathname: string","route","idleController: AbortController | null","route","newParams: URLSearchParams"],"sources":["../src/context/RouterContext.ts","../src/context/RouteContext.ts","../src/types.ts","../src/core/matchRoutes.ts","../src/core/loaderCache.ts","../src/core/navigation.ts","../src/Router.tsx","../src/Outlet.tsx","../src/hooks/useNavigate.ts","../src/hooks/useLocation.ts","../src/hooks/useParams.ts","../src/hooks/useSearchParams.ts","../src/route.ts"],"sourcesContent":["import { createContext } from \"react\";\nimport type { NavigateOptions } from \"../types.js\";\n\nexport type RouterContextValue = {\n /** Current navigation entry */\n currentEntry: NavigationHistoryEntry;\n /** Current URL */\n url: URL;\n /** Navigate to a new URL */\n navigate: (to: string, options?: NavigateOptions) => void;\n};\n\nexport const RouterContext = createContext<RouterContextValue | null>(null);\n","import { createContext, type ReactNode } from \"react\";\n\nexport type RouteContextValue = {\n /** Matched route parameters */\n params: Record<string, string>;\n /** The matched path pattern */\n matchedPath: string;\n /** Child route element to render via Outlet */\n outlet: ReactNode;\n};\n\nexport const RouteContext = createContext<RouteContextValue | null>(null);\n","import type { ComponentType } from \"react\";\nimport type { LoaderArgs, RouteDefinition } from \"./route.js\";\n\nconst InternalRouteDefinitionSymbol = Symbol();\n\n/**\n * Route definition for the router.\n * When a loader is defined, the component receives the loader result as a `data` prop.\n */\nexport type InternalRouteDefinition = {\n [InternalRouteDefinitionSymbol]: never;\n /** Path pattern to match (e.g., \"users/:id\") */\n path: string;\n /** Child routes for nested routing */\n children?: InternalRouteDefinition[];\n\n // Note: `loader` and `component` may both exist or both not exist.\n // Also, `unknown`s may actually be more specific types. They are guaranteed\n // to be the same type by the `route` helper function.\n /** Data loader function for this route */\n loader?: (args: LoaderArgs) => unknown;\n /** Component to render when this route matches */\n component?: ComponentType<{ data?: unknown }>;\n};\n\n/**\n * Converts user-defined routes to internal route definitions.\n * This function is used internally by the Router.\n *\n * Actually, this function just performs a type assertion since\n * both RouteDefinition and InternalRouteDefinition have the same runtime shape.\n */\nexport function internalRoutes(\n routes: RouteDefinition[],\n): InternalRouteDefinition[] {\n return routes as InternalRouteDefinition[];\n}\n\n/**\n * A matched route with its parameters.\n */\nexport type MatchedRoute = {\n /** The original route definition */\n route: InternalRouteDefinition;\n /** Extracted path parameters */\n params: Record<string, string>;\n /** The matched pathname segment */\n pathname: string;\n};\n\n/**\n * A matched route with loader data.\n */\nexport type MatchedRouteWithData = MatchedRoute & {\n /** Data returned from the loader (undefined if no loader) */\n data: unknown | undefined;\n};\n\n/**\n * Options for navigation.\n */\nexport type NavigateOptions = {\n /** Replace current history entry instead of pushing */\n replace?: boolean;\n /** State to associate with the navigation */\n state?: unknown;\n};\n\n/**\n * Location object representing current URL state.\n */\nexport type Location = {\n pathname: string;\n search: string;\n hash: string;\n};\n","import type { InternalRouteDefinition, MatchedRoute } from \"../types.js\";\n\n/**\n * Match a pathname against a route tree, returning the matched route stack.\n * Returns null if no match is found.\n */\nexport function matchRoutes(\n routes: InternalRouteDefinition[],\n pathname: string,\n): MatchedRoute[] | null {\n for (const route of routes) {\n const matched = matchRoute(route, pathname);\n if (matched) {\n return matched;\n }\n }\n return null;\n}\n\n/**\n * Match a single route and its children recursively.\n */\nfunction matchRoute(\n route: InternalRouteDefinition,\n pathname: string,\n): MatchedRoute[] | null {\n const hasChildren = Boolean(route.children?.length);\n\n // For parent routes (with children), we need to match as a prefix\n // For leaf routes (no children), we need an exact match\n const { matched, params, consumedPathname } = matchPath(\n route.path,\n pathname,\n !hasChildren,\n );\n\n if (!matched) {\n return null;\n }\n\n const result: MatchedRoute = {\n route,\n params,\n pathname: consumedPathname,\n };\n\n // If this route has children, try to match them\n if (hasChildren) {\n // Calculate remaining pathname, ensuring it starts with /\n let remainingPathname = pathname.slice(consumedPathname.length);\n if (!remainingPathname.startsWith(\"/\")) {\n remainingPathname = \"/\" + remainingPathname;\n }\n if (remainingPathname === \"\") {\n remainingPathname = \"/\";\n }\n\n for (const child of route.children!) {\n const childMatch = matchRoute(child, remainingPathname);\n if (childMatch) {\n // Merge params from parent into children\n return [\n result,\n ...childMatch.map((m) => ({\n ...m,\n params: { ...params, ...m.params },\n })),\n ];\n }\n }\n\n // If no children matched but this route has a component, it's still a valid match\n if (route.component) {\n return [result];\n }\n\n return null;\n }\n\n return [result];\n}\n\n/**\n * Match a path pattern against a pathname.\n */\nfunction matchPath(\n pattern: string,\n pathname: string,\n exact: boolean,\n): {\n matched: boolean;\n params: Record<string, string>;\n consumedPathname: string;\n} {\n // Normalize pattern\n const normalizedPattern = pattern.startsWith(\"/\") ? pattern : `/${pattern}`;\n\n // Build URLPattern\n let urlPatternPath: string;\n if (exact) {\n urlPatternPath = normalizedPattern;\n } else if (normalizedPattern === \"/\") {\n // Special case: root path as prefix matches anything\n urlPatternPath = \"/*\";\n } else {\n // For other prefix matches, add optional wildcard suffix\n urlPatternPath = `${normalizedPattern}{/*}?`;\n }\n\n const urlPattern = new URLPattern({ pathname: urlPatternPath });\n\n const match = urlPattern.exec({ pathname });\n if (!match) {\n return { matched: false, params: {}, consumedPathname: \"\" };\n }\n\n // Extract params (excluding the wildcard group \"0\")\n const params: Record<string, string> = {};\n for (const [key, value] of Object.entries(match.pathname.groups)) {\n if (value !== undefined && key !== \"0\") {\n params[key] = value;\n }\n }\n\n // Calculate consumed pathname\n let consumedPathname: string;\n if (exact) {\n consumedPathname = pathname;\n } else if (normalizedPattern === \"/\") {\n // Root pattern consumes just \"/\"\n consumedPathname = \"/\";\n } else {\n // For prefix matches, calculate based on pattern segments\n const patternSegments = normalizedPattern.split(\"/\").filter(Boolean);\n const pathnameSegments = pathname.split(\"/\").filter(Boolean);\n consumedPathname =\n \"/\" + pathnameSegments.slice(0, patternSegments.length).join(\"/\");\n }\n\n return { matched: true, params, consumedPathname };\n}\n","import type { LoaderArgs } from \"../route.js\";\nimport type {\n MatchedRoute,\n MatchedRouteWithData,\n InternalRouteDefinition,\n} from \"../types.js\";\n\n/**\n * Cache for loader results.\n * Key format: `${entryId}:${routePath}`\n */\nconst loaderCache = new Map<string, unknown>();\n\n/**\n * Get or create a loader result from cache.\n * If the result is not cached, executes the loader and caches the result.\n */\nfunction getOrCreateLoaderResult(\n entryId: string,\n route: InternalRouteDefinition,\n args: LoaderArgs,\n): unknown | undefined {\n if (!route.loader) {\n return undefined;\n }\n\n const cacheKey = `${entryId}:${route.path}`;\n\n if (!loaderCache.has(cacheKey)) {\n loaderCache.set(cacheKey, route.loader(args));\n }\n\n return loaderCache.get(cacheKey);\n}\n\n/**\n * Create a Request object for loader args.\n */\nexport function createLoaderRequest(url: URL): Request {\n return new Request(url.href, {\n method: \"GET\",\n });\n}\n\n/**\n * Execute loaders for matched routes and return routes with data.\n * Results are cached by navigation entry id to prevent duplicate execution.\n */\nexport function executeLoaders(\n matchedRoutes: MatchedRoute[],\n entryId: string,\n request: Request,\n signal: AbortSignal,\n): MatchedRouteWithData[] {\n return matchedRoutes.map((match) => {\n const { route, params } = match;\n const args: LoaderArgs = { params, request, signal };\n const data = getOrCreateLoaderResult(entryId, route, args);\n\n return { ...match, data };\n });\n}\n\n/**\n * Clear the loader cache.\n * Mainly used for testing.\n */\nexport function clearLoaderCache(): void {\n loaderCache.clear();\n}\n","import type { InternalRouteDefinition, NavigateOptions } from \"../types.js\";\nimport { matchRoutes } from \"./matchRoutes.js\";\nimport { executeLoaders, createLoaderRequest } from \"./loaderCache.js\";\n\n/**\n * Fallback AbortController for data loading initialized outside of a navigation event.\n * Aborted when the next navigation occurs.\n *\n * To save resources, this controller is created only when needed.\n */\nlet idleController: AbortController | null = null;\n\n/**\n * Get the current abort signal for loader cancellation.\n */\nexport function getIdleAbortSignal(): AbortSignal {\n idleController ??= new AbortController();\n return idleController.signal;\n}\n\n/**\n * Reset navigation state. Used for testing.\n */\nexport function resetNavigationState(): void {\n idleController?.abort();\n idleController = null;\n}\n\n/**\n * Check if Navigation API is available.\n */\nexport function hasNavigation(): boolean {\n return typeof navigation !== \"undefined\";\n}\n\n/**\n * Subscribe to Navigation API's currententrychange event.\n * Returns a cleanup function.\n */\nexport function subscribeToNavigation(callback: () => void): () => void {\n if (!hasNavigation()) {\n return () => {};\n }\n navigation.addEventListener(\"currententrychange\", callback);\n return () => {\n if (hasNavigation()) {\n navigation.removeEventListener(\"currententrychange\", callback);\n }\n };\n}\n\n/**\n * Get current navigation entry snapshot.\n */\nexport function getNavigationSnapshot(): NavigationHistoryEntry | null {\n if (!hasNavigation()) {\n return null;\n }\n return navigation.currentEntry;\n}\n\n/**\n * Server snapshot - Navigation API not available on server.\n */\nexport function getServerSnapshot(): null {\n return null;\n}\n\n/**\n * Set up navigation interception for client-side routing.\n * Returns a cleanup function.\n */\nexport function setupNavigationInterception(\n routes: InternalRouteDefinition[],\n): () => void {\n if (!hasNavigation()) {\n return () => {};\n }\n\n const handleNavigate = (event: NavigateEvent) => {\n // Only intercept same-origin navigations\n if (!event.canIntercept || event.hashChange) {\n return;\n }\n\n // Check if the URL matches any of our routes\n const url = new URL(event.destination.url);\n const matched = matchRoutes(routes, url.pathname);\n\n if (matched) {\n // Abort initial load's loaders if this is the first navigation\n if (idleController) {\n idleController.abort();\n idleController = null;\n }\n\n event.intercept({\n handler: async () => {\n const request = createLoaderRequest(url);\n\n // Note: in response to `currententrychange` event, <Router> should already\n // have dispatched data loaders and the results should be cached.\n // Here we run executeLoader to retrieve cached results.\n const currentEntry = navigation.currentEntry;\n if (!currentEntry) {\n throw new Error(\n \"Navigation currentEntry is null during navigation interception\",\n );\n }\n\n const results = executeLoaders(\n matched,\n currentEntry.id,\n request,\n event.signal,\n );\n\n // Delay navigation until async loaders complete\n await Promise.all(results.map((r) => r.data));\n },\n });\n }\n };\n\n navigation.addEventListener(\"navigate\", handleNavigate);\n return () => {\n if (hasNavigation()) {\n navigation.removeEventListener(\"navigate\", handleNavigate);\n }\n };\n}\n\n/**\n * Navigate to a new URL programmatically.\n */\nexport function performNavigation(to: string, options?: NavigateOptions): void {\n if (!hasNavigation()) {\n return;\n }\n navigation.navigate(to, {\n history: options?.replace ? \"replace\" : \"push\",\n state: options?.state,\n });\n}\n","import {\n type ReactNode,\n useCallback,\n useEffect,\n useMemo,\n useSyncExternalStore,\n} from \"react\";\nimport { RouterContext } from \"./context/RouterContext.js\";\nimport { RouteContext } from \"./context/RouteContext.js\";\nimport {\n type NavigateOptions,\n type MatchedRouteWithData,\n internalRoutes,\n} from \"./types.js\";\nimport { matchRoutes } from \"./core/matchRoutes.js\";\nimport {\n subscribeToNavigation,\n getNavigationSnapshot,\n getServerSnapshot,\n setupNavigationInterception,\n performNavigation,\n getIdleAbortSignal,\n} from \"./core/navigation.js\";\nimport { executeLoaders, createLoaderRequest } from \"./core/loaderCache.js\";\nimport type { RouteDefinition } from \"./route.js\";\n\nexport type RouterProps = {\n routes: RouteDefinition[];\n};\n\nexport function Router({ routes: inputRoutes }: RouterProps): ReactNode {\n const routes = internalRoutes(inputRoutes);\n\n const currentEntry = useSyncExternalStore(\n subscribeToNavigation,\n getNavigationSnapshot,\n getServerSnapshot,\n );\n\n // Set up navigation interception\n useEffect(() => {\n return setupNavigationInterception(routes);\n }, [routes]);\n\n // Navigate function for programmatic navigation\n const navigate = useCallback(\n (to: string, options?: NavigateOptions) => performNavigation(to, options),\n [],\n );\n\n return useMemo(() => {\n if (currentEntry === null) {\n // This happens either when Navigation API is unavailable,\n // or the current document is not fully active.\n return null;\n }\n const currentUrl = currentEntry.url;\n if (currentUrl === null) {\n // This means currentEntry is not in this document, which is impossible\n return null;\n }\n\n const url = new URL(currentUrl);\n const currentEntryId = currentEntry.id;\n // Match current URL against routes and execute loaders\n const matchedRoutesWithData = (() => {\n const matched = matchRoutes(routes, url.pathname);\n if (!matched) return null;\n\n // Execute loaders (results are cached by navigation entry id)\n const request = createLoaderRequest(url);\n const signal = getIdleAbortSignal();\n return executeLoaders(matched, currentEntryId, request, signal);\n })();\n\n const routerContextValue = { currentEntry, url, navigate };\n\n return (\n <RouterContext.Provider value={routerContextValue}>\n {matchedRoutesWithData ? (\n <RouteRenderer matchedRoutes={matchedRoutesWithData} index={0} />\n ) : null}\n </RouterContext.Provider>\n );\n }, [navigate, currentEntry, routes]);\n}\n\ntype RouteRendererProps = {\n matchedRoutes: MatchedRouteWithData[];\n index: number;\n};\n\n/**\n * Recursively render matched routes with proper context.\n */\nfunction RouteRenderer({\n matchedRoutes,\n index,\n}: RouteRendererProps): ReactNode {\n const match = matchedRoutes[index];\n if (!match) return null;\n\n const { route, params, pathname, data } = match;\n const Component = route.component;\n\n // Create outlet for child routes\n const outlet =\n index < matchedRoutes.length - 1 ? (\n <RouteRenderer matchedRoutes={matchedRoutes} index={index + 1} />\n ) : null;\n\n const routeContextValue = useMemo(\n () => ({ params, matchedPath: pathname, outlet }),\n [params, pathname, outlet],\n );\n\n // Render component with or without data prop based on loader presence\n const renderComponent = () => {\n if (!Component) return outlet;\n\n // When loader exists, data is defined and component expects data prop\n // When loader doesn't exist, data is undefined and component doesn't expect data prop\n // TypeScript can't narrow this union, so we use runtime check with type assertion\n if (route.loader) {\n const ComponentWithData = Component as React.ComponentType<{\n data: unknown;\n }>;\n return <ComponentWithData data={data} />;\n }\n const ComponentWithoutData = Component as React.ComponentType;\n return <ComponentWithoutData />;\n };\n\n return (\n <RouteContext.Provider value={routeContextValue}>\n {renderComponent()}\n </RouteContext.Provider>\n );\n}\n","import { type ReactNode, useContext } from \"react\";\nimport { RouteContext } from \"./context/RouteContext.js\";\n\n/**\n * Renders the matched child route.\n * Used in layout components to specify where child routes should render.\n */\nexport function Outlet(): ReactNode {\n const routeContext = useContext(RouteContext);\n\n if (!routeContext) {\n return null;\n }\n\n return routeContext.outlet;\n}\n","import { useContext } from \"react\";\nimport { RouterContext } from \"../context/RouterContext.js\";\nimport type { NavigateOptions } from \"../types.js\";\n\n/**\n * Returns a function for programmatic navigation.\n */\nexport function useNavigate(): (to: string, options?: NavigateOptions) => void {\n const context = useContext(RouterContext);\n\n if (!context) {\n throw new Error(\"useNavigate must be used within a Router\");\n }\n\n return context.navigate;\n}\n","import { useContext, useMemo } from \"react\";\nimport { RouterContext } from \"../context/RouterContext.js\";\nimport type { Location } from \"../types.js\";\n\n/**\n * Returns the current location object.\n */\nexport function useLocation(): Location {\n const context = useContext(RouterContext);\n\n if (!context) {\n throw new Error(\"useLocation must be used within a Router\");\n }\n\n const { url } = context;\n\n return useMemo(() => {\n return {\n pathname: url.pathname,\n search: url.search,\n hash: url.hash,\n };\n }, [url]);\n}\n","import { useContext } from \"react\";\nimport { RouteContext } from \"../context/RouteContext.js\";\n\n/**\n * Returns route parameters from the matched path.\n */\nexport function useParams<\n T extends Record<string, string> = Record<string, string>,\n>(): T {\n const context = useContext(RouteContext);\n\n if (!context) {\n throw new Error(\"useParams must be used within a Router\");\n }\n\n return context.params as T;\n}\n","import { useCallback, useContext, useMemo } from \"react\";\nimport { RouterContext } from \"../context/RouterContext.js\";\n\ntype SetSearchParams = (\n params:\n | URLSearchParams\n | Record<string, string>\n | ((prev: URLSearchParams) => URLSearchParams | Record<string, string>),\n) => void;\n\n/**\n * Returns and allows manipulation of URL search parameters.\n */\nexport function useSearchParams(): [URLSearchParams, SetSearchParams] {\n const context = useContext(RouterContext);\n\n if (!context) {\n throw new Error(\"useSearchParams must be used within a Router\");\n }\n\n const searchParams = context.url.searchParams;\n\n const setSearchParams = useCallback<SetSearchParams>(\n (params) => {\n const url = new URL(context.url);\n\n let newParams: URLSearchParams;\n if (typeof params === \"function\") {\n const result = params(new URLSearchParams(url.search));\n newParams =\n result instanceof URLSearchParams\n ? result\n : new URLSearchParams(result);\n } else if (params instanceof URLSearchParams) {\n newParams = params;\n } else {\n newParams = new URLSearchParams(params);\n }\n\n url.search = newParams.toString();\n context.navigate(url.pathname + url.search + url.hash, { replace: true });\n },\n [context],\n );\n\n return [searchParams, setSearchParams];\n}\n","import type { ComponentType } from \"react\";\n\nconst routeDefinitionSymbol = Symbol();\n\n/**\n * Arguments passed to loader functions.\n */\nexport type LoaderArgs = {\n /** Extracted path parameters */\n params: Record<string, string>;\n /** Request object with URL and headers */\n request: Request;\n /** AbortSignal for cancellation on navigation */\n signal: AbortSignal;\n};\n\n/**\n * Route definition created by the `route` helper function.\n */\nexport interface OpaqueRouteDefinition {\n [routeDefinitionSymbol]: never;\n path: string;\n children?: RouteDefinition[];\n}\n\n/**\n * Any route definition defined by user.\n */\nexport type RouteDefinition =\n | OpaqueRouteDefinition\n | {\n path: string;\n component?: ComponentType<{}>;\n children?: RouteDefinition[];\n };\n\n/**\n * Route definition with loader - infers TData from loader return type.\n */\ntype RouteWithLoader<TData> = {\n path: string;\n loader: (args: LoaderArgs) => TData;\n component: ComponentType<{ data: TData }>;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n children?: RouteDefinition[];\n};\n\n/**\n * Route definition without loader.\n */\ntype RouteWithoutLoader = {\n path: string;\n component?: ComponentType;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n children?: RouteDefinition[];\n};\n\n/**\n * Helper function for creating type-safe route definitions.\n *\n * When a loader is provided, TypeScript infers the return type and ensures\n * the component accepts a `data` prop of that type.\n *\n * @example\n * ```typescript\n * // Route with async loader\n * route({\n * path: \"users/:userId\",\n * loader: async ({ params, signal }) => {\n * const res = await fetch(`/api/users/${params.userId}`, { signal });\n * return res.json() as Promise<User>;\n * },\n * component: UserDetail, // Must accept { data: Promise<User> }\n * });\n *\n * // Route without loader\n * route({\n * path: \"about\",\n * component: AboutPage, // No data prop required\n * });\n * ```\n */\nexport function route<TData>(\n definition: RouteWithLoader<TData>,\n): OpaqueRouteDefinition;\nexport function route(definition: RouteWithoutLoader): OpaqueRouteDefinition;\nexport function route<TData>(\n definition: RouteWithLoader<TData> | RouteWithoutLoader,\n): OpaqueRouteDefinition {\n return definition as OpaqueRouteDefinition;\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,30 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@funstack/router",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.2",
|
|
4
4
|
"description": "A modern React router based on the Navigation API",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/uhyo/funstack-router.git"
|
|
7
|
+
"url": "git+https://github.com/uhyo/funstack-router.git",
|
|
8
|
+
"directory": "packages/router"
|
|
8
9
|
},
|
|
9
10
|
"type": "module",
|
|
10
11
|
"exports": {
|
|
11
12
|
".": {
|
|
12
|
-
"types": "./dist/index.d.
|
|
13
|
-
"import": "./dist/index.
|
|
13
|
+
"types": "./dist/index.d.mts",
|
|
14
|
+
"import": "./dist/index.mjs"
|
|
14
15
|
}
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"dist"
|
|
18
19
|
],
|
|
19
|
-
"scripts": {
|
|
20
|
-
"build": "tsdown",
|
|
21
|
-
"dev": "tsdown --watch",
|
|
22
|
-
"test": "vitest",
|
|
23
|
-
"test:run": "vitest run",
|
|
24
|
-
"typecheck": "tsc --noEmit",
|
|
25
|
-
"format": "prettier --write .",
|
|
26
|
-
"format:check": "prettier --check ."
|
|
27
|
-
},
|
|
28
20
|
"keywords": [
|
|
29
21
|
"react",
|
|
30
22
|
"router",
|
|
@@ -40,11 +32,17 @@
|
|
|
40
32
|
"@testing-library/react": "^16.3.0",
|
|
41
33
|
"@types/react": "^19.0.0",
|
|
42
34
|
"jsdom": "^27.3.0",
|
|
43
|
-
"prettier": "^3.7.4",
|
|
44
35
|
"react": "^19.0.0",
|
|
45
36
|
"tsdown": "^0.17.2",
|
|
46
37
|
"typescript": "^5.7.0",
|
|
47
38
|
"urlpattern-polyfill": "^10.1.0",
|
|
48
39
|
"vitest": "^4.0.15"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsdown",
|
|
43
|
+
"dev": "tsdown --watch",
|
|
44
|
+
"test": "vitest",
|
|
45
|
+
"test:run": "vitest run",
|
|
46
|
+
"typecheck": "tsc --noEmit"
|
|
49
47
|
}
|
|
50
|
-
}
|
|
48
|
+
}
|
package/README.md
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
# FUNSTACK Router
|
|
2
|
-
|
|
3
|
-
A modern React router built on the [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API).
|
|
4
|
-
|
|
5
|
-
> **Warning**
|
|
6
|
-
> This project is in early development and is not ready for production use. APIs may change without notice.
|
|
7
|
-
|
|
8
|
-
## Features
|
|
9
|
-
|
|
10
|
-
- **Navigation API based** - Uses the modern Navigation API instead of the History API
|
|
11
|
-
- **Object-based routes** - Define routes as plain JavaScript objects
|
|
12
|
-
- **Nested routing** - Support for layouts and nested routes with `<Outlet>`
|
|
13
|
-
- **Type-safe** - Full TypeScript support
|
|
14
|
-
- **Lightweight** - ~2.5 kB gzipped
|
|
15
|
-
|
|
16
|
-
## Installation
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
npm install @funstack/router
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## Quick Start
|
|
23
|
-
|
|
24
|
-
```tsx
|
|
25
|
-
import { Router, Link, Outlet, useParams } from "@funstack/router";
|
|
26
|
-
import type { RouteDefinition } from "@funstack/router";
|
|
27
|
-
|
|
28
|
-
function Layout() {
|
|
29
|
-
return (
|
|
30
|
-
<div>
|
|
31
|
-
<nav>
|
|
32
|
-
<Link to="/">Home</Link>
|
|
33
|
-
<Link to="/users">Users</Link>
|
|
34
|
-
</nav>
|
|
35
|
-
<Outlet />
|
|
36
|
-
</div>
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function Home() {
|
|
41
|
-
return <h1>Home</h1>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function Users() {
|
|
45
|
-
return <h1>Users</h1>;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function UserDetail() {
|
|
49
|
-
const { id } = useParams<{ id: string }>();
|
|
50
|
-
return <h1>User {id}</h1>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const routes: RouteDefinition[] = [
|
|
54
|
-
{
|
|
55
|
-
path: "/",
|
|
56
|
-
component: Layout,
|
|
57
|
-
children: [
|
|
58
|
-
{ path: "", component: Home },
|
|
59
|
-
{ path: "users", component: Users },
|
|
60
|
-
{ path: "users/:id", component: UserDetail },
|
|
61
|
-
],
|
|
62
|
-
},
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
function App() {
|
|
66
|
-
return <Router routes={routes} />;
|
|
67
|
-
}
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
## API Reference
|
|
71
|
-
|
|
72
|
-
### Components
|
|
73
|
-
|
|
74
|
-
#### `<Router>`
|
|
75
|
-
|
|
76
|
-
The root component that provides routing context.
|
|
77
|
-
|
|
78
|
-
```tsx
|
|
79
|
-
<Router routes={routes} />
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
| Prop | Type | Description |
|
|
83
|
-
| ---------- | ------------------- | ------------------------------------------- |
|
|
84
|
-
| `routes` | `RouteDefinition[]` | Array of route definitions |
|
|
85
|
-
| `children` | `ReactNode` | Optional children rendered alongside routes |
|
|
86
|
-
|
|
87
|
-
#### `<Link>`
|
|
88
|
-
|
|
89
|
-
Navigation link component.
|
|
90
|
-
|
|
91
|
-
```tsx
|
|
92
|
-
<Link to="/users" replace state={{ from: "home" }}>
|
|
93
|
-
Users
|
|
94
|
-
</Link>
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
| Prop | Type | Description |
|
|
98
|
-
| --------- | --------- | ------------------------------------- |
|
|
99
|
-
| `to` | `string` | Destination URL |
|
|
100
|
-
| `replace` | `boolean` | Replace history entry instead of push |
|
|
101
|
-
| `state` | `unknown` | State to pass to the destination |
|
|
102
|
-
|
|
103
|
-
#### `<Outlet>`
|
|
104
|
-
|
|
105
|
-
Renders the matched child route. Used in layout components.
|
|
106
|
-
|
|
107
|
-
```tsx
|
|
108
|
-
function Layout() {
|
|
109
|
-
return (
|
|
110
|
-
<div>
|
|
111
|
-
<nav>...</nav>
|
|
112
|
-
<Outlet />
|
|
113
|
-
</div>
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
### Hooks
|
|
119
|
-
|
|
120
|
-
#### `useNavigate()`
|
|
121
|
-
|
|
122
|
-
Returns a function for programmatic navigation.
|
|
123
|
-
|
|
124
|
-
```tsx
|
|
125
|
-
const navigate = useNavigate();
|
|
126
|
-
|
|
127
|
-
// Basic navigation
|
|
128
|
-
navigate("/users");
|
|
129
|
-
|
|
130
|
-
// With options
|
|
131
|
-
navigate("/users", { replace: true, state: { from: "home" } });
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
#### `useLocation()`
|
|
135
|
-
|
|
136
|
-
Returns the current location.
|
|
137
|
-
|
|
138
|
-
```tsx
|
|
139
|
-
const location = useLocation();
|
|
140
|
-
// { pathname: "/users", search: "?page=1", hash: "#section" }
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
#### `useParams()`
|
|
144
|
-
|
|
145
|
-
Returns the current route's path parameters.
|
|
146
|
-
|
|
147
|
-
```tsx
|
|
148
|
-
// Route: /users/:id
|
|
149
|
-
const { id } = useParams<{ id: string }>();
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
#### `useSearchParams()`
|
|
153
|
-
|
|
154
|
-
Returns and allows updating URL search parameters.
|
|
155
|
-
|
|
156
|
-
```tsx
|
|
157
|
-
const [searchParams, setSearchParams] = useSearchParams();
|
|
158
|
-
|
|
159
|
-
// Read
|
|
160
|
-
const page = searchParams.get("page");
|
|
161
|
-
|
|
162
|
-
// Update
|
|
163
|
-
setSearchParams({ page: "2" });
|
|
164
|
-
|
|
165
|
-
// Update with function
|
|
166
|
-
setSearchParams((prev) => {
|
|
167
|
-
prev.set("page", "2");
|
|
168
|
-
return prev;
|
|
169
|
-
});
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
### Types
|
|
173
|
-
|
|
174
|
-
#### `RouteDefinition`
|
|
175
|
-
|
|
176
|
-
```typescript
|
|
177
|
-
type RouteDefinition = {
|
|
178
|
-
path: string;
|
|
179
|
-
component?: React.ComponentType;
|
|
180
|
-
children?: RouteDefinition[];
|
|
181
|
-
};
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
#### `Location`
|
|
185
|
-
|
|
186
|
-
```typescript
|
|
187
|
-
type Location = {
|
|
188
|
-
pathname: string;
|
|
189
|
-
search: string;
|
|
190
|
-
hash: string;
|
|
191
|
-
};
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
#### `NavigateOptions`
|
|
195
|
-
|
|
196
|
-
```typescript
|
|
197
|
-
type NavigateOptions = {
|
|
198
|
-
replace?: boolean;
|
|
199
|
-
state?: unknown;
|
|
200
|
-
};
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
## Path Patterns
|
|
204
|
-
|
|
205
|
-
FUNSTACK Router uses the [URLPattern API](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) for path matching.
|
|
206
|
-
|
|
207
|
-
| Pattern | Example | Matches |
|
|
208
|
-
| ------------ | -------------- | --------------- |
|
|
209
|
-
| `/users` | `/users` | Exact match |
|
|
210
|
-
| `/users/:id` | `/users/123` | Named parameter |
|
|
211
|
-
| `/files/*` | `/files/a/b/c` | Wildcard |
|
|
212
|
-
|
|
213
|
-
## Browser Support
|
|
214
|
-
|
|
215
|
-
The Navigation API is supported in:
|
|
216
|
-
|
|
217
|
-
- Chrome 102+
|
|
218
|
-
- Edge 102+
|
|
219
|
-
- Opera 88+
|
|
220
|
-
|
|
221
|
-
Firefox and Safari do not yet support the Navigation API. For these browsers, consider using a polyfill.
|
|
222
|
-
|
|
223
|
-
## License
|
|
224
|
-
|
|
225
|
-
MIT
|