@pyreon/router 0.13.1 → 0.15.0

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.
@@ -1,4 +1,4 @@
1
- import * as _pyreon_core0 from "@pyreon/core";
1
+ import * as _$_pyreon_core0 from "@pyreon/core";
2
2
  import { ComponentFn, ComponentFn as ComponentFn$1, Props, VNodeChild } from "@pyreon/core";
3
3
  import { Computed, Signal } from "@pyreon/reactivity";
4
4
 
@@ -47,6 +47,15 @@ interface ResolvedRoute<P extends Record<string, string | undefined> = Record<st
47
47
  /** All matched records from root to leaf (one per nesting level) */
48
48
  matched: RouteRecord[];
49
49
  meta: RouteMeta;
50
+ /**
51
+ * Validated search params — populated when the matched route has `validateSearch`.
52
+ * Contains the typed result of `validateSearch(query)`. Use `useValidatedSearch()`
53
+ * to access this in components with full type inference.
54
+ * Empty object `{}` when no `validateSearch` is configured.
55
+ */
56
+ search?: Record<string, unknown> | undefined;
57
+ /** Middleware data attached during navigation (populated by middleware chain) */
58
+ _middlewareData?: Record<string, unknown> | undefined;
50
59
  }
51
60
  declare const LAZY_SYMBOL: unique symbol;
52
61
  interface LazyComponent {
@@ -102,6 +111,21 @@ interface LoaderContext {
102
111
  query: Record<string, string>;
103
112
  /** Aborted when a newer navigation supersedes this one */
104
113
  signal: AbortSignal;
114
+ /**
115
+ * The incoming HTTP `Request` — populated only when the loader runs during
116
+ * SSR (via `prefetchLoaderData`); `undefined` on every CSR navigation.
117
+ * Lets server-side loaders read cookies / auth headers and decide whether
118
+ * to `throw redirect('/login')` BEFORE the layout renders.
119
+ *
120
+ * @example
121
+ * loader: ({ request }) => {
122
+ * const cookie = request?.headers.get('cookie') ?? ''
123
+ * const sid = cookie.match(/sid=([^;]+)/)?.[1]
124
+ * if (!sid) redirect('/login')
125
+ * return { sid }
126
+ * }
127
+ */
128
+ request?: Request;
105
129
  }
106
130
  type RouteLoaderFn = (ctx: LoaderContext) => Promise<unknown>;
107
131
  interface RouteRecord<TPath extends string = string> {
@@ -144,8 +168,58 @@ interface RouteRecord<TPath extends string = string> {
144
168
  * Only applies when navigating to a route that already has cached loader data.
145
169
  */
146
170
  staleWhileRevalidate?: boolean;
171
+ /**
172
+ * Cache key function for loader data. Returns a string key derived from
173
+ * route params/query. When the key matches cached data, the loader is
174
+ * skipped (cache hit). Default: `path + JSON.stringify(params)`.
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * loaderKey: ({ params }) => `user-${params.id}`
179
+ * ```
180
+ */
181
+ loaderKey?: (ctx: Pick<LoaderContext, 'params' | 'query'>) => string;
182
+ /**
183
+ * Time in ms to keep cached loader data before garbage collection.
184
+ * Default: 300000 (5 minutes). Set to 0 to disable caching.
185
+ * Stale data is still served immediately if `staleWhileRevalidate` is true.
186
+ */
187
+ gcTime?: number;
147
188
  /** Component rendered when this route's loader throws an error */
148
189
  errorComponent?: ComponentFn$1;
190
+ /**
191
+ * Component rendered while this route's loader is running.
192
+ * Only shown after `pendingMs` (default: 0) to avoid flash on fast loads.
193
+ * Once shown, displayed for at least `pendingMinMs` (default: 200) to avoid flicker.
194
+ */
195
+ pendingComponent?: ComponentFn$1;
196
+ /** Delay in ms before showing pendingComponent (default: 0). Prevents flash on fast loaders. */
197
+ pendingMs?: number;
198
+ /** Minimum display time in ms for pendingComponent once shown (default: 200). Prevents flicker. */
199
+ pendingMinMs?: number;
200
+ /**
201
+ * Validate and transform raw query string parameters into typed values.
202
+ * Receives the raw `Record<string, string>` from the URL and returns
203
+ * a typed object. The validated result is available via `useValidatedSearch()`.
204
+ *
205
+ * Accepts any function — use Zod `.parse`, Valibot, or a plain function:
206
+ *
207
+ * @example
208
+ * ```ts
209
+ * // Plain function:
210
+ * validateSearch: (raw) => ({
211
+ * page: Number(raw.page) || 1,
212
+ * q: raw.q ?? '',
213
+ * })
214
+ *
215
+ * // With Zod:
216
+ * validateSearch: z.object({
217
+ * page: z.coerce.number().default(1),
218
+ * q: z.string().default(''),
219
+ * }).parse
220
+ * ```
221
+ */
222
+ validateSearch?: (raw: Record<string, string>) => Record<string, unknown>;
149
223
  /** Per-route middleware — runs before guards, can accumulate context data. */
150
224
  middleware?: RouteMiddleware | RouteMiddleware[];
151
225
  }
@@ -254,7 +328,14 @@ interface Router<TNames extends string = string> {
254
328
  * separately when creating the router (`createRouter({ url, ... })`) or
255
329
  * call this for the same `url` you initialised the router with.
256
330
  */
257
- preload(path: string): Promise<void>;
331
+ preload(path: string, request?: Request): Promise<void>;
332
+ /**
333
+ * Invalidate cached loader data. Forces loaders to re-run on next navigation.
334
+ * - No args: invalidate ALL cached loader data
335
+ * - String: invalidate by cache key (as returned by `loaderKey`)
336
+ * - Function: invalidate entries where the predicate returns true
337
+ */
338
+ invalidateLoader(keyOrPredicate?: string | ((key: string) => boolean)): void;
258
339
  /** Remove all event listeners, clear caches, and abort in-flight navigations. */
259
340
  destroy(): void;
260
341
  }
@@ -290,6 +371,25 @@ interface RouterInstance extends Router {
290
371
  _readyResolve: (() => void) | null;
291
372
  /** The isReady() promise instance */
292
373
  _readyPromise: Promise<void>;
374
+ /** Timestamp when the current navigation started — used for pendingMs timing */
375
+ _navigationStartTime: number;
376
+ /** Key-based loader cache: cacheKey → { data, timestamp } */
377
+ _loaderCache: Map<string, {
378
+ data: unknown;
379
+ timestamp: number;
380
+ }>;
381
+ /**
382
+ * In-flight loader dedup: cacheKey → { promise, signal }.
383
+ * Tracking the signal lets dedup skip an in-flight entry whose signal is
384
+ * already aborted — otherwise nav-2 would inherit nav-1's aborted promise
385
+ * (`router.push` aborts the previous nav's controller before starting the
386
+ * next, so back-to-back nav to the same path could resolve nav-2 against
387
+ * nav-1's aborted fetch).
388
+ */
389
+ _loaderInflight: Map<string, {
390
+ promise: Promise<unknown>;
391
+ signal: AbortSignal;
392
+ }>;
293
393
  }
294
394
  //#endregion
295
395
  //#region src/components.d.ts
@@ -338,15 +438,97 @@ interface RouterLinkProps extends Props {
338
438
  exact?: boolean;
339
439
  /**
340
440
  * Prefetch strategy for loader data:
341
- * - "hover" (default) — prefetch when the user hovers over the link
441
+ * - "intent" (default) — prefetch on hover AND focus (covers mouse + keyboard)
442
+ * - "hover" — prefetch on hover only
342
443
  * - "viewport" — prefetch when the link scrolls into the viewport
343
444
  * - "none" — no prefetching
344
445
  */
345
- prefetch?: 'hover' | 'viewport' | 'none';
446
+ prefetch?: 'intent' | 'hover' | 'viewport' | 'none';
346
447
  children?: VNodeChild | null;
347
448
  }
348
449
  declare const RouterLink: ComponentFn<RouterLinkProps>;
349
450
  //#endregion
451
+ //#region src/not-found.d.ts
452
+ /**
453
+ * Throw inside a route loader or component to trigger the nearest
454
+ * NotFoundBoundary. Inspired by Next.js's `notFound()`.
455
+ *
456
+ * @example
457
+ * ```ts
458
+ * // In a loader:
459
+ * loader: async ({ params }) => {
460
+ * const user = await fetchUser(params.id)
461
+ * if (!user) notFound()
462
+ * return user
463
+ * }
464
+ * ```
465
+ */
466
+ declare function notFound(message?: string): never;
467
+ /** Check if an error is a NotFoundError thrown by `notFound()`. */
468
+ declare function isNotFoundError(err: unknown): boolean;
469
+ interface NotFoundBoundaryProps extends Props {
470
+ /** Component or VNode to render when notFound() is thrown */
471
+ fallback: ComponentFn | VNodeChild;
472
+ children?: VNodeChild;
473
+ }
474
+ /**
475
+ * Catches `notFound()` errors from child route components or loaders
476
+ * and renders the fallback. Wraps Pyreon's ErrorBoundary with notFound
477
+ * detection — non-notFound errors propagate to parent error boundaries.
478
+ *
479
+ * @example
480
+ * ```tsx
481
+ * <NotFoundBoundary fallback={<NotFoundPage />}>
482
+ * <RouterView />
483
+ * </NotFoundBoundary>
484
+ * ```
485
+ */
486
+ declare const NotFoundBoundary: ComponentFn<NotFoundBoundaryProps>;
487
+ //#endregion
488
+ //#region src/redirect.d.ts
489
+ /** Standard redirect status codes. 307/308 preserve the request method, 302/303 don't. */
490
+ type RedirectStatus = 301 | 302 | 303 | 307 | 308;
491
+ interface RedirectInfo {
492
+ url: string;
493
+ status: RedirectStatus;
494
+ }
495
+ /**
496
+ * Throw inside a route loader to redirect the navigation server-side
497
+ * (during SSR returns a 302/307 `Location:` response) and client-side
498
+ * (during CSR triggers `router.replace()` before the layout renders).
499
+ *
500
+ * The auth-gate use case: replaces the fragile `onMount + router.push()`
501
+ * workaround. `onMount` doesn't fire reliably under nested-layout dev SSR +
502
+ * hydration — so the layout renders briefly before the push happens, leaking
503
+ * authenticated UI to unauthenticated users. `redirect()` runs in the loader
504
+ * BEFORE the layout's component is invoked, so the unauthenticated UI never
505
+ * mounts in the first place.
506
+ *
507
+ * @example
508
+ * ```ts
509
+ * // src/routes/app/_layout.tsx
510
+ * export const loader = async ({ request }) => {
511
+ * const session = await getSession(request)
512
+ * if (!session) redirect('/login')
513
+ * return { user: session.user }
514
+ * }
515
+ * ```
516
+ *
517
+ * @param url - Target URL (typically a path like `/login` or absolute URL for cross-origin).
518
+ * @param status - HTTP redirect status. Default `307` (Temporary Redirect, method-preserving).
519
+ * Use `301`/`308` for permanent moves, `302`/`303` to force GET on the target.
520
+ */
521
+ declare function redirect(url: string, status?: RedirectStatus): never;
522
+ /** Check if an error is a RedirectError thrown by `redirect()`. */
523
+ declare function isRedirectError(err: unknown): boolean;
524
+ /**
525
+ * Extract the redirect URL and status from a thrown RedirectError. Returns
526
+ * `null` if `err` isn't a RedirectError. Used by the router's loader-runner
527
+ * (CSR) and the SSR handler to convert the thrown error into the right kind
528
+ * of response (a `router.replace()` call or a `302`/`307` Response).
529
+ */
530
+ declare function getRedirectInfo(err: unknown): RedirectInfo | null;
531
+ //#endregion
350
532
  //#region src/loader.d.ts
351
533
  /**
352
534
  * Returns the data resolved by the current route's `loader` function.
@@ -365,12 +547,18 @@ declare function useLoaderData<T = unknown>(): T;
365
547
  * SSR helper: pre-run all loaders for the given path before rendering.
366
548
  * Call this before `renderToString` so route components can read data via `useLoaderData()`.
367
549
  *
550
+ * The optional `request` is forwarded to each loader's `LoaderContext.request`,
551
+ * letting server-side loaders read cookies / auth headers and `throw redirect()`
552
+ * before the layout renders. A loader that throws `redirect()` propagates the
553
+ * thrown error here — the SSR handler's `catch` converts it into a 302/307
554
+ * `Location:` Response.
555
+ *
368
556
  * @example
369
557
  * const router = createRouter({ routes, url: req.url })
370
- * await prefetchLoaderData(router, req.url)
558
+ * await prefetchLoaderData(router, req.url, request)
371
559
  * const html = await renderToString(h(App, { router }))
372
560
  */
373
- declare function prefetchLoaderData(router: RouterInstance, path: string): Promise<void>;
561
+ declare function prefetchLoaderData(router: RouterInstance, path: string, request?: Request): Promise<void>;
374
562
  /**
375
563
  * Serialize loader data to a JSON-safe plain object for embedding in SSR HTML.
376
564
  * Keys are route path patterns (stable across server and client).
@@ -399,10 +587,6 @@ declare function serializeLoaderData(router: RouterInstance): Record<string, unk
399
587
  declare function hydrateLoaderData(router: RouterInstance, serialized: Record<string, unknown>): void;
400
588
  //#endregion
401
589
  //#region src/match.d.ts
402
- /**
403
- * Parse a query string into key-value pairs. Duplicate keys are overwritten
404
- * (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
405
- */
406
590
  declare function parseQuery(qs: string): Record<string, string>;
407
591
  /**
408
592
  * Parse a query string preserving duplicate keys as arrays.
@@ -424,7 +608,7 @@ declare function buildPath(pattern: string, params: Record<string, string>): str
424
608
  declare function findRouteByName(name: string, routes: RouteRecord[]): RouteRecord | null;
425
609
  //#endregion
426
610
  //#region src/router.d.ts
427
- declare const RouterContext: _pyreon_core0.Context<RouterInstance | null>;
611
+ declare const RouterContext: _$_pyreon_core0.Context<RouterInstance | null>;
428
612
  declare function useRouter(): Router;
429
613
  declare function useRoute<TPath extends string = string>(): () => ResolvedRoute<ExtractParams<TPath> & Record<string, string>, Record<string, string>>;
430
614
  /**
@@ -526,6 +710,27 @@ declare function useSearchParams<T extends Record<string, string>>(defaults?: T)
526
710
  * ```
527
711
  */
528
712
  declare function useTypedSearchParams<T extends SearchParamSchema>(schema: T): [get: () => InferSearchParams<T>, set: (updates: Partial<InferSearchParams<T>>) => Promise<void>];
713
+ /**
714
+ * Read the validated search params from the current route's `validateSearch`.
715
+ * Returns a reactive accessor that re-evaluates when the route changes.
716
+ *
717
+ * The generic `T` should match the return type of your `validateSearch` function.
718
+ *
719
+ * @example
720
+ * ```tsx
721
+ * // Route config:
722
+ * { path: '/search', validateSearch: (raw) => ({
723
+ * page: Number(raw.page) || 1,
724
+ * q: raw.q ?? '',
725
+ * }), component: SearchPage }
726
+ *
727
+ * // In SearchPage:
728
+ * const search = useValidatedSearch<{ page: number; q: string }>()
729
+ * // search().page — typed as number
730
+ * // search().q — typed as string
731
+ * ```
732
+ */
733
+ declare function useValidatedSearch<T extends Record<string, unknown> = Record<string, unknown>>(): () => T;
529
734
  /**
530
735
  * Returns true while a navigation is in progress (guards + loaders running).
531
736
  * Use this to show loading indicators during route transitions.
@@ -556,7 +761,7 @@ declare function useTransition(): () => boolean;
556
761
  * ```
557
762
  */
558
763
  declare function useMiddlewareData(): () => Record<string, unknown>;
559
- declare function createRouter(options: RouterOptions | RouteRecord[]): Router;
764
+ declare function createRouter<TNames extends string = string>(options: RouterOptions | RouteRecord[]): Router<TNames>;
560
765
  //#endregion
561
- export { type AfterEachHook, type Blocker, type BlockerFn, type ExtractParams, type LazyComponent, type LoaderContext, type NavigationGuard, type NavigationGuardResult, type ResolvedRoute, type RouteComponent, type RouteLoaderFn, type RouteMeta, type RouteMiddleware, type RouteMiddlewareContext, type RouteRecord, type Router, RouterContext, RouterLink, type RouterLinkProps, type RouterOptions, RouterProvider, type RouterProviderProps, RouterView, type RouterViewProps, type ScrollBehaviorFn, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useIsActive, useLoaderData, useMiddlewareData, useRoute, useRouter, useSearchParams, useTransition, useTypedSearchParams };
766
+ export { type AfterEachHook, type Blocker, type BlockerFn, type ExtractParams, type LazyComponent, type LoaderContext, type NavigationGuard, type NavigationGuardResult, NotFoundBoundary, type NotFoundBoundaryProps, type RedirectStatus, type ResolvedRoute, type RouteComponent, type RouteLoaderFn, type RouteMeta, type RouteMiddleware, type RouteMiddlewareContext, type RouteRecord, type Router, RouterContext, RouterLink, type RouterLinkProps, type RouterOptions, RouterProvider, type RouterProviderProps, RouterView, type RouterViewProps, type ScrollBehaviorFn, buildPath, createRouter, findRouteByName, getRedirectInfo, hydrateLoaderData, isNotFoundError, isRedirectError, lazy, notFound, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, redirect, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useIsActive, useLoaderData, useMiddlewareData, useRoute, useRouter, useSearchParams, useTransition, useTypedSearchParams, useValidatedSearch };
562
767
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/router",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Official router for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/router#readme",
6
6
  "bugs": {
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "files": [
16
16
  "lib",
17
+ "!lib/**/*.map",
17
18
  "src",
18
19
  "README.md",
19
20
  "LICENSE"
@@ -43,14 +44,14 @@
43
44
  "prepublishOnly": "bun run build"
44
45
  },
45
46
  "dependencies": {
46
- "@pyreon/core": "^0.13.1",
47
- "@pyreon/reactivity": "^0.13.1",
48
- "@pyreon/runtime-dom": "^0.13.1"
47
+ "@pyreon/core": "^0.15.0",
48
+ "@pyreon/reactivity": "^0.15.0",
49
+ "@pyreon/runtime-dom": "^0.15.0"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@happy-dom/global-registrator": "^20.8.9",
52
53
  "@pyreon/manifest": "0.13.1",
53
- "@pyreon/test-utils": "^0.13.1",
54
+ "@pyreon/test-utils": "^0.13.2",
54
55
  "@vitest/browser-playwright": "^4.1.4",
55
56
  "happy-dom": "^20.8.3"
56
57
  }