@native-router/core 1.2.0 → 1.4.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.
package/README.md CHANGED
@@ -3,24 +3,100 @@
3
3
  [![Coverage](https://img.shields.io/codecov/c/github/native-router/core.svg)](https://codecov.io/gh/native-router/core)
4
4
  [![install size](https://packagephobia.now.sh/badge?p=@native-router/core)](https://packagephobia.now.sh/result?p=@native-router/core)
5
5
 
6
- # Native Router React
6
+ # Native Router Core
7
7
 
8
- > A route close to the native experience for react.
8
+ > Framework-agnostic routing core built on [history](https://github.com/remix-run/history) and [path-to-regexp](https://github.com/pillarjs/path-to-regexp): cancelable async navigation, an in-memory view stack and route guards.
9
9
 
10
10
  English | [简体中文](./README-zh_CN.md)
11
11
 
12
+ ## Highlights
13
+
14
+ ### Back with zero requests
15
+
16
+ Every committed navigation stores its resolved view in the router's in-memory `viewStack`. POP navigations land on the cached view through `listen` — nothing is re-matched or re-resolved.
17
+
18
+ ```ts
19
+ import {create, listen} from '@native-router/core';
20
+ import {createBrowserHistory} from 'history';
21
+
22
+ const router = create(routes, createBrowserHistory(), resolveView);
23
+
24
+ const unlisten = listen(router, (view) => {
25
+ // Back/forward lands here instantly with the cached view
26
+ mount(view);
27
+ });
28
+ ```
29
+
30
+ ### Survives a refresh
31
+
32
+ The session stack is serialized into `history.state` as a bounded tail window (`maxStackDepth`, default 100) and restored on `create`. Warm the window once after a refresh with `initHistoryStack`, and every in-window back/forward renders from cache with zero requests. Entries outside the window fall back to a single lazy re-resolve.
33
+
34
+ ```ts
35
+ const router = create(routes, createBrowserHistory(), resolveView);
36
+ // After a refresh the stack was restored from the history.state window;
37
+ // re-resolve every reachable entry so in-window back/forward are zero-request
38
+ await initHistoryStack(router);
39
+ ```
40
+
41
+ ### Guard-aware resolution for prefetching
42
+
43
+ `resolveEntry` runs the route guards (`redirect`/`beforeLoad`) and returns the terminal location together with its view task, so a link can prefetch exactly what a click would commit.
44
+
45
+ ```ts
46
+ import {resolveEntry, commit, toLocation} from '@native-router/core';
47
+
48
+ const entry = await resolveEntry(router, toLocation(router, '/users/1'));
49
+ // entry.location — the terminal location, guards applied
50
+ // entry.task — the view task of the terminal target
51
+ const view = await entry.task; // prefetch / preview
52
+ commit(router, entry.task, entry.location); // commit like a click
53
+ ```
54
+
12
55
  ## Features
13
56
 
14
- - Asynchronous navigation
15
- - Cancelable
16
- - Page data concurrent fetch
17
- - Link prefetch and preview
18
- - Most unused features can be tree-shaking
19
- - SSR support
57
+ - Framework-agnostic: bring your own `resolveView`, the view type (`V`) is yours — a string, a vdom, anything
58
+ - Route matching via path-to-regexp: declaration order, layout routes without `path`, index/fallback children with `path: ''`, strict trailing slashes, case-sensitive, nested params merged deep over shallow
59
+ - Route guards: static `redirect` and async `beforeLoad` on every route level, run shallow → deep; more than 10 chained redirects reject with `RedirectLoopError`
60
+ - Cancelable async navigation: a new resolve supersedes the in-flight one (`currentGuard`); `cancel()` aborts it; a history POP cancels it too. A superseded or cancelled `navigate()` promise **never settles** — don't `await` a navigation that might be superseded
61
+ - Navigation API: `navigate`, `refresh`, `go`/`forward`/`back`, `commit`/`commitReplace`, `createHref`, `getParams`, `match`, `toLocation`, `resolve`, `resolveTo`
62
+ - Search validation via [Standard Schema](https://standardschema.dev): a `search` schema on any route level (zod/valibot/arktype, no hard dependency), parsed with `parseSearch`/`parseSearchSync`; failures throw `SearchError`
63
+ - `preload(router, to, {ttl})`: resolve a target through the guards ahead of time, sharing one task across concurrent callers (in-flight dedup) with a TTL, default 30s; consumed entries are dropped on commit
64
+ - `errorHandler` hook turns resolve failures into fallback views
65
+ - Errors: `NativeRouterError`, `NotFoundError`, `RedirectLoopError`, `SearchError`
66
+ - Tree-shakable: `sideEffects: false`
20
67
 
21
68
  ## Matching semantics
22
69
 
23
- Routes are matched in declaration order and the first match wins — there is no sorting by specificity. Trailing slashes are significant (`/users/` does not match `/users`). Matching is case-sensitive.
70
+ - Routes match in **declaration order** and the first match wins — there is no sorting by specificity.
71
+ - A route **without `path`** is a layout: it matches the empty prefix and its children are matched against the full remaining path.
72
+ - A leaf child with **`path: ''`** matches whatever is left under its parent. Declared after its concrete siblings it serves as the parent's index route (and as the fallback for paths unmatched under the parent).
73
+ - **Trailing slashes are significant**: `/users/` does not match `/users`.
74
+ - Matching is **case-sensitive**.
75
+ - Params of nested levels are merged **deep over shallow** (`mergeMatchedParams`): for `/:id` + `/posts/:id`, the deeper `id` wins.
76
+
77
+ ## Search validation
78
+
79
+ Declare a `search` validator on a route level and parse `location.search` with it in your `resolveView`. Any [Standard Schema](https://standardschema.dev) validator works — zod, valibot and arktype all implement the interface — so the core keeps zero extra runtime dependencies.
80
+
81
+ ```ts
82
+ import {create, parseSearch} from '@native-router/core';
83
+ import {z} from 'zod';
84
+
85
+ const listSearch = z.object({page: z.coerce.number().default(1)});
86
+
87
+ const router = create(
88
+ {path: '', children: [{path: '/list', search: listSearch}]},
89
+ createBrowserHistory(),
90
+ // Your resolveView consumes route.search itself: parse the location
91
+ // search, then resolve the view from the parsed output
92
+ async (matched, {location}) =>
93
+ renderList(await parseSearch(matched.at(-1)!.route.search!, location.search))
94
+ );
95
+ ```
96
+
97
+ - `parseSearchInput(search)` degrades a query string into a plain object — single-valued keys are strings, keys repeated in the query are arrays — which is also the input every schema validates
98
+ - `parseSearch(schema, search)` resolves the schema output (async validators are awaited); `parseSearchSync` is the render/guard-time flavor and rejects async validators with a clear error
99
+ - A rejected validation throws `SearchError` (a `NativeRouterError`) carrying the raw `search` and the reported `issues` — route it through your `errorHandler` like any other resolve failure
24
100
 
25
101
  ## Install
26
102
 
@@ -30,54 +106,43 @@ npm i @native-router/core
30
106
 
31
107
  ## Usage
32
108
 
33
- ```tsx
34
- import {View, HistoryRouter as Router} from '@native-router/core';
35
- import Loading from '@/components/Loading';
36
- import RouterError from '@/components/RouterError';
37
- import * as userService from '@/services/user';
38
-
39
- export default function App() {
40
- return (
41
- <Router
42
- routes={{
43
- component: () => import('./Layout'),
44
- children: [
45
- {
46
- path: '/',
47
- component: () => import('./Home')
48
- },
49
- {
50
- path: '/users',
51
- component: () => import('./UserList'),
52
- data: userService.fetchList
53
- },
54
- {
55
- path: '/users/:id',
56
- component: () => import('./UserProfile'),
57
- data: ({id}) => userService.fetchById(+id)
58
- },
59
- {
60
- path: '/help',
61
- component: () => import('./Help')
62
- },
63
- {
64
- path: '/about',
65
- component: () => import('./About')
66
- }
67
- ]
68
- }}
69
- baseUrl="/demos"
70
- errorHandler={(e) => <RouterError error={e} />}
71
- >
72
- <View />
73
- <Loading />
74
- </Router>
75
- );
76
- }
109
+ ```ts
110
+ import {create, listen, navigate} from '@native-router/core';
111
+ import {createBrowserHistory} from 'history';
112
+
113
+ const router = create(
114
+ {
115
+ path: '', // layout level: children match the full remaining path
116
+ children: [{path: '/'}, {path: '/users/:id'}]
117
+ },
118
+ createBrowserHistory(),
119
+ // Resolve the matched levels into a view of your own
120
+ async (matched, {location}) => renderApp(matched, location),
121
+ {baseUrl: '', errorHandler: (e) => renderError(e)}
122
+ );
77
123
 
124
+ const unlisten = listen(router, (view) => {
125
+ // Called on every navigation; POP hits the cached view directly
126
+ mount(view);
127
+ });
128
+
129
+ await navigate(router, '/users/1'); // guards run, then commit pushes the view
130
+ ```
131
+
132
+ Any extra route fields (e.g. `component`, `data`) pass through to your `resolveView` untouched — that is how `@native-router/react` builds its conventions on top of the core.
133
+
134
+ ## Development
135
+
136
+ `@native-router/core` (this package) and `@native-router/react` live in **two independent repositories**; clone them side by side. The react repo's vitest config aliases `@native-router/core` to `../core/src`, so its tests exercise the latest core source without any install-level linking.
137
+
138
+ ```bash
139
+ pnpm install
140
+ pnpm test # core tests
141
+ pnpm build # build core dist
78
142
  ```
79
- See [demos](/demos/) for a complete example.
80
143
 
81
- ## Documentation
144
+ React's type check and production build resolve core from the npm registry, so publish core first when react needs to consume unpublished core APIs.
145
+
146
+ ## Documentation
82
147
 
83
148
  [API](https://native-router.github.io/core/modules.html)