@funstack/router 0.0.1-alpha.1 → 0.0.1

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 CHANGED
@@ -1,29 +1,135 @@
1
- import * as react0 from "react";
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
+ * Extracts parameter names from a path pattern.
7
+ * E.g., "/users/:id/posts/:postId" -> "id" | "postId"
8
+ */
9
+ type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}` ? Param | ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? Param : never;
10
+ /**
11
+ * Creates a params object type from a path pattern.
12
+ * E.g., "/users/:id" -> { id: string }
13
+ */
14
+ type PathParams<T extends string> = [ExtractParams<T>] extends [never] ? Record<string, never> : { [K in ExtractParams<T>]: string };
15
+ /**
16
+ * Arguments passed to loader functions.
17
+ */
18
+ type LoaderArgs = {
19
+ /** Extracted path parameters */
20
+ params: Record<string, string>;
21
+ /** Request object with URL and headers */
22
+ request: Request;
23
+ /** AbortSignal for cancellation on navigation */
24
+ signal: AbortSignal;
25
+ };
26
+ /**
27
+ * Route definition created by the `route` helper function.
28
+ */
29
+ interface OpaqueRouteDefinition {
30
+ [routeDefinitionSymbol]: never;
31
+ path: string;
32
+ children?: RouteDefinition[];
33
+ }
34
+ /**
35
+ * Any route definition defined by user.
36
+ */
37
+ type RouteDefinition = OpaqueRouteDefinition | {
38
+ path: string;
39
+ component?: ComponentType<{}>;
40
+ children?: RouteDefinition[];
41
+ };
42
+ /**
43
+ * Route definition with loader - infers TData from loader return type.
44
+ * TPath is used to infer params type from the path pattern.
45
+ */
46
+ type RouteWithLoader<TPath extends string, TData> = {
47
+ path: TPath;
48
+ loader: (args: LoaderArgs) => TData;
49
+ component: ComponentType<{
50
+ data: TData;
51
+ params: PathParams<TPath>;
52
+ }>;
53
+ children?: RouteDefinition[];
54
+ };
55
+ /**
56
+ * Route definition without loader.
57
+ * TPath is used to infer params type from the path pattern.
58
+ */
59
+ type RouteWithoutLoader<TPath extends string> = {
60
+ path: TPath;
61
+ component?: ComponentType<{
62
+ params: PathParams<TPath>;
63
+ }>;
64
+ children?: RouteDefinition[];
65
+ };
66
+ /**
67
+ * Helper function for creating type-safe route definitions.
68
+ *
69
+ * When a loader is provided, TypeScript infers the return type and ensures
70
+ * the component accepts a `data` prop of that type. Components always receive
71
+ * a `params` prop with types inferred from the path pattern.
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * // Route with async loader
76
+ * route({
77
+ * path: "users/:userId",
78
+ * loader: async ({ params, signal }) => {
79
+ * const res = await fetch(`/api/users/${params.userId}`, { signal });
80
+ * return res.json() as Promise<User>;
81
+ * },
82
+ * component: UserDetail, // Must accept { data: Promise<User>, params: { userId: string } }
83
+ * });
84
+ *
85
+ * // Route without loader
86
+ * route({
87
+ * path: "about",
88
+ * component: AboutPage, // Must accept { params: {} }
89
+ * });
90
+ * ```
91
+ */
92
+ declare function route<TPath extends string, TData>(definition: RouteWithLoader<TPath, TData>): OpaqueRouteDefinition;
93
+ declare function route<TPath extends string>(definition: RouteWithoutLoader<TPath>): OpaqueRouteDefinition;
94
+ //#endregion
4
95
  //#region src/types.d.ts
96
+ declare const InternalRouteDefinitionSymbol: unique symbol;
5
97
  /**
6
98
  * Route definition for the router.
99
+ * When a loader is defined, the component receives the loader result as a `data` prop.
7
100
  */
8
- type RouteDefinition = {
101
+ type InternalRouteDefinition = {
102
+ [InternalRouteDefinitionSymbol]: never;
9
103
  /** Path pattern to match (e.g., "users/:id") */
10
104
  path: string;
11
- /** Component to render when this route matches */
12
- component?: ComponentType;
13
105
  /** Child routes for nested routing */
14
- children?: RouteDefinition[];
106
+ children?: InternalRouteDefinition[];
107
+ /** Data loader function for this route */
108
+ loader?: (args: LoaderArgs) => unknown;
109
+ /** Component to render when this route matches */
110
+ component?: ComponentType<{
111
+ data?: unknown;
112
+ params?: Record<string, string>;
113
+ }>;
15
114
  };
16
115
  /**
17
116
  * A matched route with its parameters.
18
117
  */
19
118
  type MatchedRoute = {
20
119
  /** The original route definition */
21
- route: RouteDefinition;
120
+ route: InternalRouteDefinition;
22
121
  /** Extracted path parameters */
23
122
  params: Record<string, string>;
24
123
  /** The matched pathname segment */
25
124
  pathname: string;
26
125
  };
126
+ /**
127
+ * A matched route with loader data.
128
+ */
129
+ type MatchedRouteWithData = MatchedRoute & {
130
+ /** Data returned from the loader (undefined if no loader) */
131
+ data: unknown | undefined;
132
+ };
27
133
  /**
28
134
  * Options for navigation.
29
135
  */
@@ -41,37 +147,47 @@ type Location = {
41
147
  search: string;
42
148
  hash: string;
43
149
  };
150
+ /**
151
+ * Callback invoked before navigation is intercepted.
152
+ * Call `event.preventDefault()` to prevent the router from handling this navigation.
153
+ *
154
+ * @param event - The NavigateEvent from the Navigation API
155
+ * @param matched - Array of matched routes, or null if no routes matched
156
+ */
157
+ type OnNavigateCallback = (event: NavigateEvent, matched: readonly MatchedRoute[] | null) => void;
158
+ /**
159
+ * Fallback mode when Navigation API is unavailable.
160
+ *
161
+ * - `"none"` (default): Render nothing when Navigation API is unavailable
162
+ * - `"static"`: Render matched routes without navigation capabilities (MPA behavior)
163
+ */
164
+ type FallbackMode = "none" | "static";
44
165
  //#endregion
45
166
  //#region src/Router.d.ts
46
167
  type RouterProps = {
47
168
  routes: RouteDefinition[];
48
- children?: ReactNode;
169
+ /**
170
+ * Callback invoked before navigation is intercepted.
171
+ * Call `event.preventDefault()` to prevent the router from handling this navigation.
172
+ *
173
+ * @param event - The NavigateEvent from the Navigation API
174
+ * @param matched - Array of matched routes, or null if no routes matched
175
+ */
176
+ onNavigate?: OnNavigateCallback;
177
+ /**
178
+ * Fallback mode when Navigation API is unavailable.
179
+ *
180
+ * - `"none"` (default): Render nothing when Navigation API is unavailable
181
+ * - `"static"`: Render matched routes without navigation capabilities (MPA behavior)
182
+ */
183
+ fallback?: FallbackMode;
49
184
  };
50
185
  declare function Router({
51
- routes,
52
- children
186
+ routes: inputRoutes,
187
+ onNavigate,
188
+ fallback
53
189
  }: RouterProps): ReactNode;
54
190
  //#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
191
  //#region src/Outlet.d.ts
76
192
  /**
77
193
  * Renders the matched child route.
@@ -104,5 +220,19 @@ type SetSearchParams = (params: URLSearchParams | Record<string, string> | ((pre
104
220
  */
105
221
  declare function useSearchParams(): [URLSearchParams, SetSearchParams];
106
222
  //#endregion
107
- export { Link, type LinkProps, type Location, type MatchedRoute, type NavigateOptions, Outlet, type RouteDefinition, Router, type RouterProps, useLocation, useNavigate, useParams, useSearchParams };
223
+ //#region src/core/RouterAdapter.d.ts
224
+ /**
225
+ * Represents the current location state.
226
+ * Abstracts NavigationHistoryEntry for static mode compatibility.
227
+ */
228
+ type LocationEntry = {
229
+ /** The current URL */
230
+ url: URL;
231
+ /** Unique key for this entry (used for loader caching) */
232
+ key: string;
233
+ /** State associated with this entry */
234
+ state: unknown;
235
+ };
236
+ //#endregion
237
+ export { type FallbackMode, type LoaderArgs, type Location, type LocationEntry, type MatchedRoute, type MatchedRouteWithData, type NavigateOptions, type OnNavigateCallback, Outlet, type PathParams, type RouteDefinition, Router, type RouterProps, route, useLocation, useNavigate, useParams, useSearchParams };
108
238
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/Router.tsx","../src/Link.tsx","../src/Outlet.tsx","../src/hooks/useNavigate.ts","../src/hooks/useLocation.ts","../src/hooks/useParams.ts","../src/hooks/useSearchParams.ts"],"sourcesContent":[],"mappings":";;;;;;;AAKY,KAAA,eAAA,GAAe;EAYf;EAYA,IAAA,EAAA,MAAA;EAUA;cA9BE;;aAED;ACKb,CAAA;AA4CA;;;AAA6C,KD3CjC,YAAA,GC2CiC;EAAc;EAAS,KAAA,EDzC3D,eCyC2D;;UDvC1D;;EEZE,QAAA,EAAA,MAAS;CACE;;;;AASD,KFUV,eAAA,GEVU;EAGT;EAAI,OAAA,CAAA,EAAA,OAAA;EAAA;EAAA,KAAA,CAAA,EAAA,OAAA;CAHJ;;;;AAGI,KFiBL,QAAA,GEjBK;;;;ACfjB,CAAA;;;KFSY,WAAA;UACF;EDZE,QAAA,CAAA,ECaC,SDbc;AAY3B,CAAA;AAYY,iBC+BI,MAAA,CD/BW;EAAA,MAAA;EAAA;AAAA,CAAA,EC+BkB,WD/BlB,CAAA,EC+BgC,SD/BhC;;;KEpBf,SAAA,GAAY,KACtB,qBAAqB;;;EFLX;EAYA,OAAA,CAAA,EAAA,OAAY;EAYZ;EAUA,KAAA,CAAA,EAAA,OAAQ;aEpBP;;cAGA,aAAI,0BAAA,KAAA,qBAAA;EDNL;EA4CI,EAAA,EAAA,MAAM;EAAG;EAAQ,OAAA,CAAA,EAAA,OAAA;EAAY;EAAc,KAAA,CAAA,EAAA,OAAA;EAAS,QAAA,CAAA,ECzCvD,SDyCuD;;;;;;;ADvDpE;AAYY,iBGVI,MAAA,CAAA,CHYP,EGZiB,SHYjB;;;;;;AAdG,iBIEI,WAAA,CAAA,CJEF,EAAA,CAAA,EAAA,EAAA,MAED,EAAA,OAAe,CAAf,EIJyC,eJI1B,EAAA,GAAA,IAAA;;;;;;AANhB,iBKEI,WAAA,CAAA,CLEF,EKFiB,QLEjB;;;;;;iBMHE,oBACJ,yBAAyB,2BAChC;;;KCLA,eAAA,YAEC,kBACA,iCACQ,oBAAoB,kBAAkB;;;;APFxC,iBOQI,eAAA,CAAA,CPJF,EAAA,COIsB,ePFvB,EOEwC,ePFzB,CAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/route.ts","../src/types.ts","../src/Router.tsx","../src/Outlet.tsx","../src/hooks/useNavigate.ts","../src/hooks/useLocation.ts","../src/hooks/useParams.ts","../src/hooks/useSearchParams.ts","../src/core/RouterAdapter.ts"],"sourcesContent":[],"mappings":"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"}
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { createContext, forwardRef, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
2
- import { jsx, jsxs } from "react/jsx-runtime";
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,72 +97,250 @@ function matchPath(pattern, pathname, exact) {
84
97
  }
85
98
 
86
99
  //#endregion
87
- //#region src/Router.tsx
100
+ //#region src/core/loaderCache.ts
88
101
  /**
89
- * Check if Navigation API is available.
102
+ * Cache for loader results.
103
+ * Key format: `${entryId}:${routePath}`
90
104
  */
91
- function hasNavigation() {
92
- return typeof navigation !== "undefined";
93
- }
105
+ const loaderCache = /* @__PURE__ */ new Map();
94
106
  /**
95
- * Subscribe to Navigation API's currententrychange event.
107
+ * Get or create a loader result from cache.
108
+ * If the result is not cached, executes the loader and caches the result.
96
109
  */
97
- function subscribeToNavigation(callback) {
98
- if (!hasNavigation()) return () => {};
99
- navigation.addEventListener("currententrychange", callback);
100
- return () => {
101
- if (hasNavigation()) navigation.removeEventListener("currententrychange", callback);
102
- };
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);
103
115
  }
104
116
  /**
105
- * Get current navigation entry snapshot.
117
+ * Create a Request object for loader args.
106
118
  */
107
- function getNavigationSnapshot() {
108
- if (!hasNavigation()) return null;
109
- return navigation.currentEntry;
119
+ function createLoaderRequest(url) {
120
+ return new Request(url.href, { method: "GET" });
110
121
  }
111
122
  /**
112
- * Server snapshot - Navigation API not available on server.
123
+ * Execute loaders for matched routes and return routes with data.
124
+ * Results are cached by navigation entry id to prevent duplicate execution.
113
125
  */
114
- function getServerSnapshot() {
115
- return null;
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
+ });
116
139
  }
117
- function Router({ routes, children }) {
118
- const currentEntry = useSyncExternalStore(subscribeToNavigation, getNavigationSnapshot, getServerSnapshot);
119
- useEffect(() => {
120
- if (!hasNavigation()) return;
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 () => {} });
140
+
141
+ //#endregion
142
+ //#region src/core/NavigationAPIAdapter.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
+ * Adapter that uses the Navigation API for full SPA functionality.
152
+ */
153
+ var NavigationAPIAdapter = class {
154
+ cachedSnapshot = null;
155
+ cachedEntryId = null;
156
+ getSnapshot() {
157
+ const entry = navigation.currentEntry;
158
+ if (!entry?.url) return null;
159
+ if (this.cachedEntryId === entry.id && this.cachedSnapshot) return this.cachedSnapshot;
160
+ this.cachedEntryId = entry.id;
161
+ this.cachedSnapshot = {
162
+ url: new URL(entry.url),
163
+ key: entry.id,
164
+ state: entry.getState()
124
165
  };
125
- navigation.addEventListener("navigate", handleNavigate);
166
+ return this.cachedSnapshot;
167
+ }
168
+ getServerSnapshot() {
169
+ return null;
170
+ }
171
+ subscribe(callback) {
172
+ const controller = new AbortController();
173
+ navigation.addEventListener("currententrychange", callback, { signal: controller.signal });
126
174
  return () => {
127
- if (hasNavigation()) navigation.removeEventListener("navigate", handleNavigate);
175
+ controller.abort();
128
176
  };
129
- }, [routes]);
130
- const navigate = useCallback((to, options) => {
131
- if (!hasNavigation()) return;
177
+ }
178
+ navigate(to, options) {
132
179
  navigation.navigate(to, {
133
180
  history: options?.replace ? "replace" : "push",
134
181
  state: options?.state
135
182
  });
136
- }, []);
137
- const currentUrl = currentEntry?.url;
138
- const matchedRoutes = useMemo(() => {
139
- if (!currentUrl) return null;
140
- return matchRoutes(routes, new URL(currentUrl).pathname);
141
- }, [currentUrl, routes]);
142
- const routerContextValue = useMemo(() => ({
143
- currentEntry,
144
- navigate
145
- }), [currentEntry, navigate]);
146
- return /* @__PURE__ */ jsxs(RouterContext.Provider, {
147
- value: routerContextValue,
148
- children: [matchedRoutes ? /* @__PURE__ */ jsx(RouteRenderer, {
149
- matchedRoutes,
150
- index: 0
151
- }) : null, children]
152
- });
183
+ }
184
+ setupInterception(routes, onNavigate) {
185
+ const handleNavigate = (event) => {
186
+ if (!event.canIntercept || event.hashChange) return;
187
+ const url = new URL(event.destination.url);
188
+ const matched = matchRoutes(routes, url.pathname);
189
+ if (onNavigate) {
190
+ onNavigate(event, matched);
191
+ if (event.defaultPrevented) return;
192
+ }
193
+ if (matched) {
194
+ if (idleController) {
195
+ idleController.abort();
196
+ idleController = null;
197
+ }
198
+ event.intercept({ handler: async () => {
199
+ const request = createLoaderRequest(url);
200
+ const currentEntry = navigation.currentEntry;
201
+ if (!currentEntry) throw new Error("Navigation currentEntry is null during navigation interception");
202
+ const results = executeLoaders(matched, currentEntry.id, request, event.signal);
203
+ await Promise.all(results.map((r) => r.data));
204
+ } });
205
+ }
206
+ };
207
+ const controller = new AbortController();
208
+ navigation.addEventListener("navigate", handleNavigate, { signal: controller.signal });
209
+ return () => {
210
+ controller.abort();
211
+ };
212
+ }
213
+ getIdleAbortSignal() {
214
+ idleController ??= new AbortController();
215
+ return idleController.signal;
216
+ }
217
+ };
218
+
219
+ //#endregion
220
+ //#region src/core/StaticAdapter.ts
221
+ /**
222
+ * Static adapter for fallback mode when Navigation API is unavailable.
223
+ * Provides read-only location access with no SPA navigation capabilities.
224
+ * Links will cause full page loads (MPA behavior).
225
+ */
226
+ var StaticAdapter = class {
227
+ cachedSnapshot = null;
228
+ idleController = null;
229
+ getSnapshot() {
230
+ if (typeof window === "undefined") return null;
231
+ if (!this.cachedSnapshot) this.cachedSnapshot = {
232
+ url: new URL(window.location.href),
233
+ key: "__static__",
234
+ state: void 0
235
+ };
236
+ return this.cachedSnapshot;
237
+ }
238
+ getServerSnapshot() {
239
+ return null;
240
+ }
241
+ subscribe(_callback) {
242
+ return () => {};
243
+ }
244
+ navigate(to, _options) {
245
+ console.warn("FUNSTACK Router: navigate() called in static fallback mode. Navigation API is not available in this browser. Links will cause full page loads.");
246
+ }
247
+ setupInterception(_routes, _onNavigate) {}
248
+ getIdleAbortSignal() {
249
+ this.idleController ??= new AbortController();
250
+ return this.idleController.signal;
251
+ }
252
+ };
253
+
254
+ //#endregion
255
+ //#region src/core/NullAdapter.ts
256
+ /**
257
+ * Null adapter for when Navigation API is unavailable and no fallback is configured.
258
+ * All methods are no-ops that return safe default values.
259
+ */
260
+ var NullAdapter = class {
261
+ idleController = null;
262
+ getSnapshot() {
263
+ return null;
264
+ }
265
+ getServerSnapshot() {
266
+ return null;
267
+ }
268
+ subscribe(_callback) {
269
+ return () => {};
270
+ }
271
+ navigate(_to, _options) {
272
+ console.warn("FUNSTACK Router: navigate() called but no adapter is available. Navigation API is not available in this browser and no fallback mode is configured.");
273
+ }
274
+ setupInterception(_routes, _onNavigate) {}
275
+ getIdleAbortSignal() {
276
+ this.idleController ??= new AbortController();
277
+ return this.idleController.signal;
278
+ }
279
+ };
280
+
281
+ //#endregion
282
+ //#region src/core/createAdapter.ts
283
+ /**
284
+ * Check if Navigation API is available.
285
+ */
286
+ function hasNavigation() {
287
+ return typeof window !== "undefined" && "navigation" in window;
288
+ }
289
+ /**
290
+ * Create the appropriate router adapter based on browser capabilities
291
+ * and the specified fallback mode.
292
+ *
293
+ * @param fallback - The fallback mode to use when Navigation API is unavailable
294
+ * @returns A RouterAdapter instance
295
+ */
296
+ function createAdapter(fallback) {
297
+ if (hasNavigation()) return new NavigationAPIAdapter();
298
+ if (fallback === "static") return new StaticAdapter();
299
+ return new NullAdapter();
300
+ }
301
+
302
+ //#endregion
303
+ //#region src/Router.tsx
304
+ function Router({ routes: inputRoutes, onNavigate, fallback = "none" }) {
305
+ const routes = internalRoutes(inputRoutes);
306
+ const adapter = useMemo(() => createAdapter(fallback), [fallback]);
307
+ const locationEntry = useSyncExternalStore(useCallback((callback) => adapter.subscribe(callback), [adapter]), () => adapter.getSnapshot(), () => adapter.getServerSnapshot());
308
+ useEffect(() => {
309
+ return adapter.setupInterception(routes, onNavigate);
310
+ }, [
311
+ adapter,
312
+ routes,
313
+ onNavigate
314
+ ]);
315
+ const navigate = useCallback((to, options) => {
316
+ adapter.navigate(to, options);
317
+ }, [adapter]);
318
+ return useMemo(() => {
319
+ if (locationEntry === null) return null;
320
+ const { url, key } = locationEntry;
321
+ const matchedRoutesWithData = (() => {
322
+ const matched = matchRoutes(routes, url.pathname);
323
+ if (!matched) return null;
324
+ return executeLoaders(matched, key, createLoaderRequest(url), adapter.getIdleAbortSignal());
325
+ })();
326
+ const routerContextValue = {
327
+ locationEntry,
328
+ url,
329
+ navigate
330
+ };
331
+ return /* @__PURE__ */ jsx(RouterContext.Provider, {
332
+ value: routerContextValue,
333
+ children: matchedRoutesWithData ? /* @__PURE__ */ jsx(RouteRenderer, {
334
+ matchedRoutes: matchedRoutesWithData,
335
+ index: 0
336
+ }) : null
337
+ });
338
+ }, [
339
+ navigate,
340
+ locationEntry,
341
+ routes,
342
+ adapter
343
+ ]);
153
344
  }
154
345
  /**
155
346
  * Recursively render matched routes with proper context.
@@ -157,8 +348,8 @@ function Router({ routes, children }) {
157
348
  function RouteRenderer({ matchedRoutes, index }) {
158
349
  const match = matchedRoutes[index];
159
350
  if (!match) return null;
160
- const { route, params, pathname } = match;
161
- const Component = route.component;
351
+ const { route: route$1, params, pathname, data } = match;
352
+ const Component = route$1.component;
162
353
  const outlet = index < matchedRoutes.length - 1 ? /* @__PURE__ */ jsx(RouteRenderer, {
163
354
  matchedRoutes,
164
355
  index: index + 1
@@ -172,52 +363,20 @@ function RouteRenderer({ matchedRoutes, index }) {
172
363
  pathname,
173
364
  outlet
174
365
  ]);
366
+ const renderComponent = () => {
367
+ if (!Component) return outlet;
368
+ if (route$1.loader) return /* @__PURE__ */ jsx(Component, {
369
+ data,
370
+ params
371
+ });
372
+ return /* @__PURE__ */ jsx(Component, { params });
373
+ };
175
374
  return /* @__PURE__ */ jsx(RouteContext.Provider, {
176
375
  value: routeContextValue,
177
- children: Component ? /* @__PURE__ */ jsx(Component, {}) : outlet
376
+ children: renderComponent()
178
377
  });
179
378
  }
180
379
 
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
380
  //#endregion
222
381
  //#region src/Outlet.tsx
223
382
  /**
@@ -230,6 +389,17 @@ function Outlet() {
230
389
  return routeContext.outlet;
231
390
  }
232
391
 
392
+ //#endregion
393
+ //#region src/hooks/useNavigate.ts
394
+ /**
395
+ * Returns a function for programmatic navigation.
396
+ */
397
+ function useNavigate() {
398
+ const context = useContext(RouterContext);
399
+ if (!context) throw new Error("useNavigate must be used within a Router");
400
+ return context.navigate;
401
+ }
402
+
233
403
  //#endregion
234
404
  //#region src/hooks/useLocation.ts
235
405
  /**
@@ -238,19 +408,14 @@ function Outlet() {
238
408
  function useLocation() {
239
409
  const context = useContext(RouterContext);
240
410
  if (!context) throw new Error("useLocation must be used within a Router");
411
+ const { url } = context;
241
412
  return useMemo(() => {
242
- if (!context.currentEntry?.url) return {
243
- pathname: "/",
244
- search: "",
245
- hash: ""
246
- };
247
- const url = new URL(context.currentEntry.url);
248
413
  return {
249
414
  pathname: url.pathname,
250
415
  search: url.search,
251
416
  hash: url.hash
252
417
  };
253
- }, [context.currentEntry?.url]);
418
+ }, [url]);
254
419
  }
255
420
 
256
421
  //#endregion
@@ -272,13 +437,8 @@ function useParams() {
272
437
  function useSearchParams() {
273
438
  const context = useContext(RouterContext);
274
439
  if (!context) throw new Error("useSearchParams must be used within a Router");
275
- return [useMemo(() => {
276
- if (!context.currentEntry?.url) return new URLSearchParams();
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);
440
+ return [context.url.searchParams, useCallback((params) => {
441
+ const url = new URL(context.url);
282
442
  let newParams;
283
443
  if (typeof params === "function") {
284
444
  const result = params(new URLSearchParams(url.search));
@@ -291,5 +451,11 @@ function useSearchParams() {
291
451
  }
292
452
 
293
453
  //#endregion
294
- export { Link, Outlet, Router, useLocation, useNavigate, useParams, useSearchParams };
454
+ //#region src/route.ts
455
+ function route(definition) {
456
+ return definition;
457
+ }
458
+
459
+ //#endregion
460
+ export { Outlet, Router, route, useLocation, useNavigate, useParams, useSearchParams };
295
461
  //# sourceMappingURL=index.mjs.map
@@ -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/NavigationAPIAdapter.ts","../src/core/StaticAdapter.ts","../src/core/NullAdapter.ts","../src/core/createAdapter.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\";\nimport type { LocationEntry } from \"../core/RouterAdapter.js\";\n\nexport type RouterContextValue = {\n /** Current location entry */\n locationEntry: LocationEntry;\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<{\n data?: unknown;\n params?: Record<string, string>;\n }>;\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\n/**\n * Callback invoked before navigation is intercepted.\n * Call `event.preventDefault()` to prevent the router from handling this navigation.\n *\n * @param event - The NavigateEvent from the Navigation API\n * @param matched - Array of matched routes, or null if no routes matched\n */\nexport type OnNavigateCallback = (\n event: NavigateEvent,\n matched: readonly MatchedRoute[] | null,\n) => void;\n\n/**\n * Fallback mode when Navigation API is unavailable.\n *\n * - `\"none\"` (default): Render nothing when Navigation API is unavailable\n * - `\"static\"`: Render matched routes without navigation capabilities (MPA behavior)\n */\nexport type FallbackMode =\n | \"none\" // Default: render nothing when Navigation API unavailable\n | \"static\"; // Render matched routes without navigation capabilities\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 { RouterAdapter, LocationEntry } from \"./RouterAdapter.js\";\nimport type {\n InternalRouteDefinition,\n NavigateOptions,\n OnNavigateCallback,\n} 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 * Reset navigation state. Used for testing.\n */\nexport function resetNavigationState(): void {\n idleController?.abort();\n idleController = null;\n}\n\n/**\n * Adapter that uses the Navigation API for full SPA functionality.\n */\nexport class NavigationAPIAdapter implements RouterAdapter {\n // Cache the snapshot to ensure referential stability for useSyncExternalStore\n private cachedSnapshot: LocationEntry | null = null;\n private cachedEntryId: string | null = null;\n\n getSnapshot(): LocationEntry | null {\n const entry = navigation.currentEntry;\n if (!entry?.url) {\n return null;\n }\n\n // Return cached snapshot if entry hasn't changed\n if (this.cachedEntryId === entry.id && this.cachedSnapshot) {\n return this.cachedSnapshot;\n }\n\n // Create new snapshot and cache it\n this.cachedEntryId = entry.id;\n this.cachedSnapshot = {\n url: new URL(entry.url),\n key: entry.id,\n state: entry.getState(),\n };\n return this.cachedSnapshot;\n }\n\n getServerSnapshot(): LocationEntry | null {\n return null;\n }\n\n subscribe(callback: () => void): () => void {\n const controller = new AbortController();\n navigation.addEventListener(\"currententrychange\", callback, {\n signal: controller.signal,\n });\n return () => {\n controller.abort();\n };\n }\n\n navigate(to: string, options?: NavigateOptions): void {\n navigation.navigate(to, {\n history: options?.replace ? \"replace\" : \"push\",\n state: options?.state,\n });\n }\n\n setupInterception(\n routes: InternalRouteDefinition[],\n onNavigate?: OnNavigateCallback,\n ): (() => void) | undefined {\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 // Call onNavigate callback if provided (regardless of route match)\n if (onNavigate) {\n onNavigate(event, matched);\n if (event.defaultPrevented) {\n return; // Do not intercept, allow browser default\n }\n }\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 const controller = new AbortController();\n navigation.addEventListener(\"navigate\", handleNavigate, {\n signal: controller.signal,\n });\n return () => {\n controller.abort();\n };\n }\n\n getIdleAbortSignal(): AbortSignal {\n idleController ??= new AbortController();\n return idleController.signal;\n }\n}\n","import type { RouterAdapter, LocationEntry } from \"./RouterAdapter.js\";\nimport type {\n InternalRouteDefinition,\n NavigateOptions,\n OnNavigateCallback,\n} from \"../types.js\";\n\n/**\n * Static adapter for fallback mode when Navigation API is unavailable.\n * Provides read-only location access with no SPA navigation capabilities.\n * Links will cause full page loads (MPA behavior).\n */\nexport class StaticAdapter implements RouterAdapter {\n private cachedSnapshot: LocationEntry | null = null;\n private idleController: AbortController | null = null;\n\n getSnapshot(): LocationEntry | null {\n if (typeof window === \"undefined\") {\n return null;\n }\n\n // Cache the snapshot - it never changes in static mode\n if (!this.cachedSnapshot) {\n this.cachedSnapshot = {\n url: new URL(window.location.href),\n key: \"__static__\",\n state: undefined,\n };\n }\n return this.cachedSnapshot;\n }\n\n getServerSnapshot(): LocationEntry | null {\n return null;\n }\n\n subscribe(_callback: () => void): () => void {\n // Static mode never fires location change events\n return () => {};\n }\n\n navigate(to: string, _options?: NavigateOptions): void {\n console.warn(\n \"FUNSTACK Router: navigate() called in static fallback mode. \" +\n \"Navigation API is not available in this browser. \" +\n \"Links will cause full page loads.\",\n );\n // Note: We intentionally do NOT do window.location.href = to\n // as that would mask bugs where developers expect SPA behavior.\n // If needed in the future, we could add a \"static-reload\" mode.\n }\n\n setupInterception(\n _routes: InternalRouteDefinition[],\n _onNavigate?: OnNavigateCallback,\n ): (() => void) | undefined {\n // No interception in static mode - links cause full page loads\n return undefined;\n }\n\n getIdleAbortSignal(): AbortSignal {\n this.idleController ??= new AbortController();\n return this.idleController.signal;\n }\n}\n","import type { RouterAdapter, LocationEntry } from \"./RouterAdapter.js\";\nimport type {\n InternalRouteDefinition,\n NavigateOptions,\n OnNavigateCallback,\n} from \"../types.js\";\n\n/**\n * Null adapter for when Navigation API is unavailable and no fallback is configured.\n * All methods are no-ops that return safe default values.\n */\nexport class NullAdapter implements RouterAdapter {\n private idleController: AbortController | null = null;\n\n getSnapshot(): LocationEntry | null {\n return null;\n }\n\n getServerSnapshot(): LocationEntry | null {\n return null;\n }\n\n subscribe(_callback: () => void): () => void {\n return () => {};\n }\n\n navigate(_to: string, _options?: NavigateOptions): void {\n console.warn(\n \"FUNSTACK Router: navigate() called but no adapter is available. \" +\n \"Navigation API is not available in this browser and no fallback mode is configured.\",\n );\n }\n\n setupInterception(\n _routes: InternalRouteDefinition[],\n _onNavigate?: OnNavigateCallback,\n ): (() => void) | undefined {\n return undefined;\n }\n\n getIdleAbortSignal(): AbortSignal {\n this.idleController ??= new AbortController();\n return this.idleController.signal;\n }\n}\n","import type { RouterAdapter } from \"./RouterAdapter.js\";\nimport { NavigationAPIAdapter } from \"./NavigationAPIAdapter.js\";\nimport { StaticAdapter } from \"./StaticAdapter.js\";\nimport { NullAdapter } from \"./NullAdapter.js\";\nimport type { FallbackMode } from \"../types.js\";\n\n/**\n * Check if Navigation API is available.\n */\nfunction hasNavigation(): boolean {\n return typeof window !== \"undefined\" && \"navigation\" in window;\n}\n\n/**\n * Create the appropriate router adapter based on browser capabilities\n * and the specified fallback mode.\n *\n * @param fallback - The fallback mode to use when Navigation API is unavailable\n * @returns A RouterAdapter instance\n */\nexport function createAdapter(fallback: FallbackMode): RouterAdapter {\n // Try Navigation API first\n if (hasNavigation()) {\n return new NavigationAPIAdapter();\n }\n\n // Fall back to static mode if enabled\n if (fallback === \"static\") {\n return new StaticAdapter();\n }\n\n // No adapter available (fallback=\"none\" or default)\n return new NullAdapter();\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 type OnNavigateCallback,\n type FallbackMode,\n internalRoutes,\n} from \"./types.js\";\nimport { matchRoutes } from \"./core/matchRoutes.js\";\nimport { createAdapter } from \"./core/createAdapter.js\";\nimport { executeLoaders, createLoaderRequest } from \"./core/loaderCache.js\";\nimport type { RouteDefinition } from \"./route.js\";\n\nexport type RouterProps = {\n routes: RouteDefinition[];\n /**\n * Callback invoked before navigation is intercepted.\n * Call `event.preventDefault()` to prevent the router from handling this navigation.\n *\n * @param event - The NavigateEvent from the Navigation API\n * @param matched - Array of matched routes, or null if no routes matched\n */\n onNavigate?: OnNavigateCallback;\n /**\n * Fallback mode when Navigation API is unavailable.\n *\n * - `\"none\"` (default): Render nothing when Navigation API is unavailable\n * - `\"static\"`: Render matched routes without navigation capabilities (MPA behavior)\n */\n fallback?: FallbackMode;\n};\n\nexport function Router({\n routes: inputRoutes,\n onNavigate,\n fallback = \"none\",\n}: RouterProps): ReactNode {\n const routes = internalRoutes(inputRoutes);\n\n // Create adapter once based on browser capabilities and fallback setting\n const adapter = useMemo(() => createAdapter(fallback), [fallback]);\n\n // Subscribe to location changes via adapter\n const locationEntry = useSyncExternalStore(\n useCallback((callback) => adapter.subscribe(callback), [adapter]),\n () => adapter.getSnapshot(),\n () => adapter.getServerSnapshot(),\n );\n\n // Set up navigation interception via adapter\n useEffect(() => {\n return adapter.setupInterception(routes, onNavigate);\n }, [adapter, routes, onNavigate]);\n\n // Navigate function from adapter\n const navigate = useCallback(\n (to: string, options?: NavigateOptions) => {\n adapter.navigate(to, options);\n },\n [adapter],\n );\n\n return useMemo(() => {\n if (locationEntry === null) {\n // This happens either when Navigation API is unavailable (and no fallback),\n // or the current document is not fully active.\n return null;\n }\n\n const { url, key } = locationEntry;\n\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 location entry key)\n const request = createLoaderRequest(url);\n const signal = adapter.getIdleAbortSignal();\n return executeLoaders(matched, key, request, signal);\n })();\n\n const routerContextValue = { locationEntry, 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, locationEntry, routes, adapter]);\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 // Always pass params prop to components\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 params: Record<string, string>;\n }>;\n return <ComponentWithData data={data} params={params} />;\n }\n const ComponentWithoutData = Component as React.ComponentType<{\n params: Record<string, string>;\n }>;\n return <ComponentWithoutData params={params} />;\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 * Extracts parameter names from a path pattern.\n * E.g., \"/users/:id/posts/:postId\" -> \"id\" | \"postId\"\n */\ntype ExtractParams<T extends string> =\n T extends `${string}:${infer Param}/${infer Rest}`\n ? Param | ExtractParams<`/${Rest}`>\n : T extends `${string}:${infer Param}`\n ? Param\n : never;\n\n/**\n * Creates a params object type from a path pattern.\n * E.g., \"/users/:id\" -> { id: string }\n */\nexport type PathParams<T extends string> = [ExtractParams<T>] extends [never]\n ? Record<string, never>\n : { [K in ExtractParams<T>]: string };\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 * TPath is used to infer params type from the path pattern.\n */\ntype RouteWithLoader<TPath extends string, TData> = {\n path: TPath;\n loader: (args: LoaderArgs) => TData;\n component: ComponentType<{ data: TData; params: PathParams<TPath> }>;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n children?: RouteDefinition[];\n};\n\n/**\n * Route definition without loader.\n * TPath is used to infer params type from the path pattern.\n */\ntype RouteWithoutLoader<TPath extends string> = {\n path: TPath;\n component?: ComponentType<{ params: PathParams<TPath> }>;\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. Components always receive\n * a `params` prop with types inferred from the path pattern.\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>, params: { userId: string } }\n * });\n *\n * // Route without loader\n * route({\n * path: \"about\",\n * component: AboutPage, // Must accept { params: {} }\n * });\n * ```\n */\nexport function route<TPath extends string, TData>(\n definition: RouteWithLoader<TPath, TData>,\n): OpaqueRouteDefinition;\nexport function route<TPath extends string>(\n definition: RouteWithoutLoader<TPath>,\n): OpaqueRouteDefinition;\nexport function route<TPath extends string, TData>(\n definition: RouteWithLoader<TPath, TData> | RouteWithoutLoader<TPath>,\n): OpaqueRouteDefinition {\n return definition as unknown 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.1",
3
+ "version": "0.0.1",
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.ts",
13
- "import": "./dist/index.js"
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",
@@ -37,14 +29,20 @@
37
29
  },
38
30
  "devDependencies": {
39
31
  "@testing-library/jest-dom": "^6.9.1",
40
- "@testing-library/react": "^16.3.0",
32
+ "@testing-library/react": "^16.3.1",
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
- "tsdown": "^0.17.2",
36
+ "tsdown": "^0.18.0",
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