@native-router/react 1.5.0 → 1.6.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/README.md CHANGED
@@ -74,7 +74,8 @@ function Preview({visible}: {visible: boolean}) {
74
74
  - `NavLink` with `isActive`/`isExactActive`, `end`, `caseSensitive` and `aria-current` (defaults to `"page"`); `className`/`style`/`children` accept `({isActive, isExactActive})` callbacks; `to="/"` is active for every path
75
75
  - Polymorphic links: every link component takes an `as` component — own props flattened and type-checked on the link, colliding props through the `asProps` escape hatch, `href`/`onClick`/`aria-current` injected, `ref` forwarded
76
76
  - `useSearchParams` reads and writes the query string; writes push by default or replace with `{replace: true}`; `useSetSearch(schema)` is the schema-aware setter twin of `useSearch(schema)` — the next value is validated by the same schema before any navigation, a rejection throws `SearchError` without touching the location, and the written query is the schema's own output(defaults applied)
77
- - Typed search: an optional Standard Schema validator (zod/valibot/arktype, no hard dependency) on any route `search` field, parsed at resolve time — loaders receive a typed `ctx.search` and an invalid search fails the level through the existing error layers; `useSearch(schema?)` reads it in components, degrading to the raw object without a schema
77
+ - Typed search: an optional Standard Schema validator (zod/valibot/arktype, no hard dependency) on any route `search` field, parsed at resolve time — `data` loaders and `beforeLoad` guards receive a typed `ctx.search` and an invalid search fails the level through the existing error layers; `useSearch(schema?)` reads it in components, degrading to the raw object without a schema
78
+ - Search type closure: `createRoutes(routes)` re-types the returned table so every level's `data`/`beforeLoad` `ctx.search` derives from the level's own schema — no `Route<P, S>` generics or callback annotations needed; an explicit `Route<P, S>` generic still wins wherever written
78
79
  - Type-safe links: `createRoutes(routes)` checks the table while keeping every `path` literal, `RoutePaths<typeof routes>` extracts the pattern union(through nesting and param segments), and `<TypedLink<RoutePaths<...>> to params>` narrows `to` to the table and checks `params` against the exact pattern's segments — compile errors for unknown paths and missing/wrong params, click-time interpolation with encoding as the runtime backstop
79
80
  - `ScrollRestoration` restores the scroll offset per history entry on back/forward and resets it on push (`resetOnPush` to opt out)
80
81
  - Router-level `preload(router, to)` shares resolved views across links with in-flight dedup and a 30s TTL; `PrefetchLink` prefetch through it
@@ -140,6 +141,15 @@ The navigation semantics stay owned by the link: `href` is always the computed t
140
141
 
141
142
  `PrefetchLink`'s strategies keep working through the `as` component; the `viewport` strategy observes the DOM node the component forwards its ref to, so a component that never forwards the ref down to a DOM element simply never triggers a viewport prefetch.
142
143
 
144
+ ## Why `useData` is typed manually
145
+
146
+ `useData<T>()` annotations have no compile-time link to the route's `data` loader — the annotation *is* the contract. That is deliberate. Two closure schemes were evaluated (2026-08) and rejected:
147
+
148
+ - **A from-argument** (`useData('/articles/:slug')`, indexing a route-table map by path literal — TanStack's `useLoaderData({from})` shape). Rejected: it makes every view aware of the path it happens to be mounted under. Matching data to a view is the route configuration's job; a view should know what it renders, not where it is mounted.
149
+ - **A data-props protocol** — constrain `component` to `ComponentType<{data: D}>` and let `createRoutes` check the loader output against it at the config site. The check lands at the right layer, but deep children would then need prop drilling to reach the data.
150
+
151
+ What stays: path-agnostic views, no prop drilling, one local annotation. Revisit only if TypeScript or the library later offers a channel that couples neither paths nor props.
152
+
143
153
  ## Install
144
154
 
145
155
  ```bash
@@ -259,11 +269,12 @@ import {ScrollRestoration} from '@native-router/react';
259
269
 
260
270
  On mount it also sets `history.scrollRestoration` to `manual`: the browser's own `auto` restoration would race the component's restore and pre-scroll while the left entry's offset is still being read, so the component owns scroll restoration for the session (the setting is not reverted on unmount).
261
271
 
262
- Validate and type the search with a schema — any zod/valibot/arktype schema works, the router only speaks [Standard Schema](https://standardschema.dev). Declare it once on the route and the search is parsed during resolve: the `data` loader receives a typed `ctx.search` (coerced numbers, defaults applied), and an invalid search fails the level through the existing error layers — the route `errorComponent`, else the global `errorHandler`.
272
+ Validate and type the search with a schema — any zod/valibot/arktype schema works, the router only speaks [Standard Schema](https://standardschema.dev). Declare it once on the route and the search is parsed during resolve: the `data` loader and the `beforeLoad` guard receive a typed `ctx.search` (coerced numbers, defaults applied), and an invalid search fails the level through the existing error layers — the route `errorComponent`, else the global `errorHandler`.
273
+
274
+ Build the table with `createRoutes` and the typing closes by itself: the returned table derives every level's `ctx.search` from the level's own schema, so neither the manual `Route<P, S>` generic nor callback annotations are needed. (Callbacks written inside the literal are checked loosely — `ctx.search: any` — since TypeScript cannot contextually type a member from sibling properties; the precise types hold on the returned table, and a callback annotation that contradicts the schema is rejected at the property.)
263
275
 
264
276
  ```tsx
265
- import {useData, useSearch} from '@native-router/react';
266
- import type {Route} from '@native-router/react';
277
+ import {createRoutes, useData, useSearch} from '@native-router/react';
267
278
  import {z} from 'zod';
268
279
 
269
280
  const listSearch = z.object({
@@ -271,14 +282,19 @@ const listSearch = z.object({
271
282
  tag: z.string().optional()
272
283
  });
273
284
 
274
- const listRoute = {
275
- path: '/articles',
276
- search: listSearch,
277
- component: () => import('./ArticleList'),
278
- // ctx.search: {page: number; tag?: string} — parsed and typed
279
- data: ({search}) => fetchArticles(search.page, search.tag),
280
- errorComponent: ({error}) => <p>{error.message}</p>
281
- } as Route<'/articles', {page: number; tag?: string}>;
285
+ const routes = createRoutes({
286
+ component: () => import('./Layout'),
287
+ children: [
288
+ {
289
+ path: '/articles',
290
+ search: listSearch,
291
+ component: () => import('./ArticleList'),
292
+ // typeof routes → this level's ctx.search: {page: number; tag?: string}
293
+ data: ({search}) => fetchArticles(search.page, search.tag),
294
+ errorComponent: ({error}) => <p>{error.message}</p>
295
+ }
296
+ ]
297
+ });
282
298
 
283
299
  function ArticleList() {
284
300
  const articles = useData<Article[]>(); // typed, no casts
@@ -288,6 +304,16 @@ function ArticleList() {
288
304
  }
289
305
  ```
290
306
 
307
+ Hand-annotated route objects keep the explicit generic — it wins wherever written:
308
+
309
+ ```tsx
310
+ const listRoute = {
311
+ path: '/articles',
312
+ search: listSearch,
313
+ component: () => import('./ArticleList')
314
+ } as Route<'/articles', {page: number; tag?: string}>;
315
+ ```
316
+
291
317
  `useSearch()` without a schema degrades to the raw input object of `parseSearchInput` (strings; repeated keys are arrays) and needs no schema on the route. Both flavors re-render on every location change, and the schema must validate synchronously.
292
318
 
293
319
  Write the search through the same schema — `useSetSearch(schema)` validates the next value before any navigation, throws `SearchError` (with the schema's issues) without touching the location when it rejects, and writes the schema's own output so defaults apply:
@@ -1,4 +1,4 @@
1
- import type { Route } from './types';
1
+ import type { Route, SearchRoutesOf } from './types';
2
2
  /**
3
3
  * Identity function with `satisfies` semantics: the table is checked
4
4
  * against `Route` while every `path` keeps its string-literal type, so
@@ -6,22 +6,37 @@ import type { Route } from './types';
6
6
  * `TypedLink`. An `as Route` assertion does the opposite — it widens
7
7
  * every `path` to `string` and gives up the literals.
8
8
  *
9
+ * The return type additionally closes the search loop(see
10
+ * {@link SearchRoutesOf}): every level's `data` loader and `beforeLoad`
11
+ * guard receive their `ctx.search` typed from the level's own
12
+ * {@link Route.search search schema} —
13
+ *
9
14
  * ```tsx
10
15
  * const routes = createRoutes({
11
16
  * children: [
12
- * {path: '/', component: () => import('./Home')},
13
- * {path: '/users/:id', component: () => import('./UserProfile')}
17
+ * {
18
+ * path: '/list',
19
+ * search: z.object({page: z.coerce.number()}),
20
+ * // typeof routes → ctx.search: {page: number}, no annotations
21
+ * data: ({search}) => fetchList(search.page)
22
+ * }
14
23
  * ]
15
24
  * });
16
- * // type AppPaths = '/' | '/users/:id'
17
- * type AppPaths = RoutePaths<typeof routes>;
18
25
  * ```
19
26
  *
27
+ * Callbacks written inside the literal are still checked loosely
28
+ * against `Route`(`ctx.search: any` — TypeScript cannot contextually
29
+ * type a member from sibling properties); the precise types hold on the
30
+ * returned table, and a callback whose annotation contradicts the
31
+ * schema is rejected at the property. An explicit `Route<P, S>` generic
32
+ * keeps priority wherever it is written.
33
+ *
20
34
  * Zero runtime cost: the function returns its argument unchanged and
21
35
  * tree-shakes away.
22
36
  * @group Methods
23
37
  * @category Route
24
38
  * @param routes the route table, a route object or an array of them
25
- * @returns the very same route table, literal types preserved
39
+ * @returns the very same route table, literal types preserved and the
40
+ * loader/guard search contexts re-typed from the schemas
26
41
  */
27
- export declare function createRoutes<const T>(routes: T & (Route | Route[])): T;
42
+ export declare function createRoutes<const T>(routes: T & SearchRoutesOf<T> & (Route | Route[])): SearchRoutesOf<T>;
@@ -1,5 +1,5 @@
1
1
  import type { AnchorHTMLAttributes, ComponentPropsWithRef, ComponentPropsWithoutRef, ComponentType, CSSProperties, DetailedHTMLProps, ElementType, ReactNode } from 'react';
2
- import type { BaseRoute, ExtractPathParams, Matched, Location, RouterInstance, SearchInput } from '@native-router/core';
2
+ import type { Awaitable, BaseRoute, ExtractPathParams, GuardContext, Matched, Location, RouterInstance, SearchInput, StandardSchemaV1 } from '@native-router/core';
3
3
  export type ResolveViewContext<R extends BaseRoute> = {
4
4
  router: RouterInstance<R>;
5
5
  location: Location;
@@ -55,14 +55,18 @@ export type RouteParams<P extends string> = string extends P ? Record<string, st
55
55
  * `Route<'/users/:id'>` → `params: {id: string}`. Give the second
56
56
  * generic the output type of the route `search` schema and `ctx.search`
57
57
  * is typed accordingly, e.g. `Route<'/list', {page: number}>` with
58
- * `search: z.object({page: z.coerce.number()})` → `search: {page: number}`.
58
+ * `search: z.object({page: z.coerce.number()})` → `search: {page: number}` —
59
+ * for the `data` loader and the `beforeLoad` guard alike.
59
60
  *
60
61
  * Without the search generic an untyped `ctx.search` stays `any` — the
61
62
  * default that keeps differently typed levels assignable to plain
62
63
  * `Route`: schema outputs are arbitrary(coerced numbers, defaults, ...),
63
64
  * so no single degraded shape is bivariant with all of them. At runtime
64
65
  * it holds the raw input object of `parseSearchInput`; see `useSearch`
65
- * for the typed degraded shape.
66
+ * for the typed degraded shape. Prefer {@link createRoutes}: its
67
+ * returned table derives every level's `ctx.search` from the level's own
68
+ * schema(see {@link SearchRoutesOf}), so the manual generic is only
69
+ * needed for hand-annotated route objects.
66
70
  * `children` accepts `Route<any, any>` so levels with different patterns
67
71
  * and search shapes nest without variance conflicts.
68
72
  * @group Types
@@ -99,7 +103,17 @@ export type Route<P extends string = string, S = any> = Omit<BaseRoute<{
99
103
  * Receives no props.
100
104
  */
101
105
  pendingComponent?: ComponentType;
102
- }>, 'path' | 'children'> & {
106
+ }>, 'path' | 'children' | 'beforeLoad'> & {
107
+ /**
108
+ * Route guard inherited from `BaseRoute`, re-typed by the search
109
+ * generic: `ctx.search` is `S`(`any` by default — see the Route doc
110
+ * above). At runtime it holds the level's parsed search — the schema
111
+ * output, or the degraded input without a schema. The guard context
112
+ * types `router` as `RouterInstance<any>`: a precise
113
+ * `RouterInstance<Route>` here would recurse into `Route`'s own
114
+ * members and break `Route`'s assignability to plain `BaseRoute`.
115
+ */
116
+ beforeLoad?(ctx: GuardContext<any, S>): Awaitable<string | void>;
103
117
  /** Path pattern; params of the contexts above are inferred from it. */
104
118
  path?: P;
105
119
  /**
@@ -114,6 +128,59 @@ export type LoadStatus = {
114
128
  key: number;
115
129
  status: 'pending' | 'resolved' | 'rejected';
116
130
  };
131
+ /**
132
+ * The search a route's `data`/`beforeLoad` contexts receive: the
133
+ * level's own {@link Route.search search schema} output, or the
134
+ * degraded {@link SearchInput} when the level declares no schema.
135
+ * Building block of {@link SearchRoutesOf}.
136
+ * @group Types
137
+ * @category Route
138
+ */
139
+ export type RouteSearchOf<R> = R extends {
140
+ search: StandardSchemaV1<any, infer Output>;
141
+ } ? Output : SearchInput;
142
+ /**
143
+ * A context with only its `search` member replaced — everything the
144
+ * callback declared(`params` precision, `signal`, custom shapes)
145
+ * passes through untouched.
146
+ * @group Types
147
+ * @category Route
148
+ */
149
+ export type WithSearch<C, S> = Omit<C, 'search'> & {
150
+ search: S;
151
+ };
152
+ /**
153
+ * Re-type a route table so every level's `data` loader and `beforeLoad`
154
+ * guard derive their `ctx.search` from the level's own
155
+ * {@link Route.search search schema}: the schema's parsed output(see
156
+ * {@link RouteSearchOf}) instead of the loose `any`. This is what
157
+ * {@link createRoutes} returns, closing the type loop — no manual
158
+ * `Route<P, S>` generics or callback annotations needed for the search
159
+ * typing.
160
+ *
161
+ * Everything else passes through unchanged: `path` literals(so
162
+ * `RoutePaths<typeof routes>` and `TypedLink` keep working), the
163
+ * loaders' return types, and the rest of every level's members. A
164
+ * callback that annotates its ctx with a search shape the schema
165
+ * contradicts is rejected at the `data`/`beforeLoad` property; an
166
+ * un-annotated callback written inside the literal is checked loosely
167
+ * against `Route`(`ctx.search: any` — TypeScript cannot contextually
168
+ * type a member from sibling properties) and precisely on the returned
169
+ * table.
170
+ * @group Types
171
+ * @category Route
172
+ */
173
+ export type SearchRoutesOf<T> = T extends readonly unknown[] ? {
174
+ -readonly [K in keyof T]: SearchRoutesOf<T[K]>;
175
+ } : T extends Route ? T extends {
176
+ data?: infer Data;
177
+ beforeLoad?: infer BeforeLoad;
178
+ children?: infer Children;
179
+ } ? Omit<T, 'data' | 'beforeLoad' | 'children'> & {
180
+ data?: [unknown] extends [Data] ? undefined : Data extends (ctx: infer DataCtx) => infer R ? (ctx: WithSearch<DataCtx, RouteSearchOf<T>>) => R : Data;
181
+ beforeLoad?: [unknown] extends [BeforeLoad] ? undefined : BeforeLoad extends (ctx: infer GuardCtx) => infer R ? (ctx: WithSearch<GuardCtx, RouteSearchOf<T>>) => R : BeforeLoad;
182
+ children?: [unknown] extends [Children] ? undefined : SearchRoutesOf<Children>;
183
+ } : T : T;
117
184
  /**
118
185
  * Union of every navigable path pattern of a route table, computed from
119
186
  * the table's type. Each level's `path` literal concatenates with its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@native-router/react",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -62,7 +62,7 @@
62
62
  "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
63
63
  },
64
64
  "dependencies": {
65
- "@native-router/core": "^1.7.0",
65
+ "@native-router/core": "^1.8.0",
66
66
  "history": "^5.3.0",
67
67
  "use-sync-external-store": "^1.6.0"
68
68
  },