jotai-iten 0.0.1 → 0.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 +600 -0
- package/dist/components/Link.cjs +21 -0
- package/dist/components/Link.d.cts +13 -0
- package/dist/components/Link.d.ts +13 -0
- package/dist/components/Link.mjs +21 -0
- package/dist/components/Navigate.cjs +15 -0
- package/dist/components/Navigate.d.cts +10 -0
- package/dist/components/Navigate.d.ts +10 -0
- package/dist/components/Navigate.mjs +15 -0
- package/dist/components/Route.cjs +22 -0
- package/dist/components/Route.d.cts +17 -0
- package/dist/components/Route.d.ts +17 -0
- package/dist/components/Route.mjs +22 -0
- package/dist/components/Switch.cjs +21 -0
- package/dist/components/Switch.d.cts +9 -0
- package/dist/components/Switch.d.ts +9 -0
- package/dist/components/Switch.mjs +21 -0
- package/dist/create-router.cjs +22 -0
- package/dist/create-router.d.cts +18 -0
- package/dist/create-router.d.ts +18 -0
- package/dist/create-router.mjs +22 -0
- package/dist/headless-core.cjs +54 -0
- package/dist/headless-core.d.cts +17 -0
- package/dist/headless-core.d.ts +17 -0
- package/dist/headless-core.mjs +54 -0
- package/dist/headless.cjs +46 -0
- package/dist/headless.d.cts +4 -0
- package/dist/headless.d.ts +4 -0
- package/dist/headless.mjs +3 -0
- package/dist/hooks.cjs +48 -0
- package/dist/hooks.d.cts +20 -0
- package/dist/hooks.d.ts +20 -0
- package/dist/hooks.mjs +48 -0
- package/dist/index.cjs +48 -0
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.mjs +4 -0
- package/dist/types.d.cts +30 -0
- package/dist/types.d.ts +30 -0
- package/dist/url.cjs +23 -0
- package/dist/url.d.cts +18 -0
- package/dist/url.d.ts +18 -0
- package/dist/url.mjs +22 -0
- package/dist/utils.cjs +44 -0
- package/dist/utils.d.cts +3 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.mjs +2 -0
- package/dist/zod.cjs +77 -0
- package/dist/zod.d.cts +48 -0
- package/dist/zod.d.ts +48 -0
- package/dist/zod.mjs +73 -0
- package/package.json +122 -5
- package/src/components/Link.tsx +36 -0
- package/src/components/Navigate.tsx +26 -0
- package/src/components/Route.tsx +53 -0
- package/src/components/Switch.tsx +32 -0
- package/src/create-router.ts +37 -0
- package/src/headless-core.ts +126 -0
- package/src/headless.ts +31 -0
- package/src/hooks.ts +64 -0
- package/src/index.ts +36 -0
- package/src/types.ts +62 -0
- package/src/url.ts +54 -0
- package/src/utils.ts +23 -0
- package/src/zod.ts +150 -0
- package/index.js +0 -1
package/README.md
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
# jotai-iten
|
|
2
|
+
|
|
3
|
+
Typed in-memory routing for React apps that already use Jotai.
|
|
4
|
+
|
|
5
|
+
`jotai-iten` is built for embedded UI surfaces: Figma plugins, VS Code webviews, browser extension panels, Electron sidebars, iframe widgets, modal stacks, and internal tools where a browser URL is absent or secondary. It gives you typed route state, loader orchestration, guards, pending state, history, and scoped atoms without adopting a URL-first router.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install jotai-iten jotai react
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Peer dependencies:
|
|
16
|
+
|
|
17
|
+
- `jotai@^2`
|
|
18
|
+
- `react@^18.3 || ^19`
|
|
19
|
+
|
|
20
|
+
Optional:
|
|
21
|
+
|
|
22
|
+
- `@tanstack/react-query@^5` if your loaders call `ensureQueryData`
|
|
23
|
+
- `zod@^4` if you import `jotai-iten/zod`
|
|
24
|
+
|
|
25
|
+
`iten-core` is installed transitively by `jotai-iten`. Install it directly only if you are using the framework-agnostic core without the Jotai adapter.
|
|
26
|
+
|
|
27
|
+
Entry points:
|
|
28
|
+
|
|
29
|
+
- `jotai-iten` for the full router with components, hooks, atoms, and utilities
|
|
30
|
+
- `jotai-iten/headless` for hooks and atoms without component helpers
|
|
31
|
+
- `jotai-iten/zod` for optional schema-backed route factories and unknown-input parsing
|
|
32
|
+
- `jotai-iten/url` for optional URL synchronization around explicit parse/format hooks
|
|
33
|
+
- `jotai-iten/utils` for route factories, guards, and exhaustive matching
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Mental Model
|
|
38
|
+
|
|
39
|
+
A route is a discriminated union:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const routes = defineRoutes({
|
|
43
|
+
home: route(),
|
|
44
|
+
detail: route<{ id: string }>(),
|
|
45
|
+
settings: route(),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
type Routes = InferRoutes<typeof routes>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Navigation is an async pipeline:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
navigate({ target })
|
|
55
|
+
-> beforeLoad guard
|
|
56
|
+
-> optional loader
|
|
57
|
+
-> commit target route
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The current route stays mounted until the target route is ready. Loader failures keep the previous route and expose a retryable error.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// router.ts
|
|
68
|
+
import {
|
|
69
|
+
createRouter,
|
|
70
|
+
createRoute,
|
|
71
|
+
defineRoutes,
|
|
72
|
+
type InferRoutes,
|
|
73
|
+
route,
|
|
74
|
+
} from 'jotai-iten'
|
|
75
|
+
|
|
76
|
+
const routes = defineRoutes({
|
|
77
|
+
home: route(),
|
|
78
|
+
detail: route<{ id: string }>(),
|
|
79
|
+
settings: route(),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
type Routes = InferRoutes<typeof routes>
|
|
83
|
+
|
|
84
|
+
export const toRoute = createRoute(routes)
|
|
85
|
+
|
|
86
|
+
export const router = createRouter<Routes, unknown>({
|
|
87
|
+
initial: toRoute({ name: 'home' }),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
export const {
|
|
91
|
+
Route,
|
|
92
|
+
Switch,
|
|
93
|
+
Link,
|
|
94
|
+
Navigate,
|
|
95
|
+
useNavigate,
|
|
96
|
+
useRoute,
|
|
97
|
+
useCurrentRoute,
|
|
98
|
+
} = router
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
// App.tsx
|
|
103
|
+
import { Navigate, Route, Switch, toRoute } from './router'
|
|
104
|
+
|
|
105
|
+
export function App() {
|
|
106
|
+
return (
|
|
107
|
+
<Switch>
|
|
108
|
+
<Route name="home">{() => <HomeView />}</Route>
|
|
109
|
+
<Route name="detail">{({ id }) => <DetailView id={id} />}</Route>
|
|
110
|
+
<Route name="settings">{() => <SettingsView />}</Route>
|
|
111
|
+
<Navigate to={toRoute({ name: 'home' })} />
|
|
112
|
+
</Switch>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
No router provider is required. Use the normal Jotai provider only if your app already uses a custom store.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Headless Router
|
|
122
|
+
|
|
123
|
+
Use `jotai-iten/headless` when you want atoms and hooks but do not need `Route`, `Switch`, `Link`, or `Navigate`. This keeps component helpers out of hook-only bundles.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import {
|
|
127
|
+
createHeadlessRouter,
|
|
128
|
+
createRoute,
|
|
129
|
+
defineRoutes,
|
|
130
|
+
type InferRoutes,
|
|
131
|
+
route,
|
|
132
|
+
} from 'jotai-iten/headless'
|
|
133
|
+
|
|
134
|
+
const routes = defineRoutes({
|
|
135
|
+
home: route(),
|
|
136
|
+
detail: route<{ id: string }>(),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
type Routes = InferRoutes<typeof routes>
|
|
140
|
+
|
|
141
|
+
export const toRoute = createRoute(routes)
|
|
142
|
+
|
|
143
|
+
export const router = createHeadlessRouter<Routes, unknown>({
|
|
144
|
+
initial: toRoute({ name: 'home' }),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
export const { atoms, useNavigate, useRoute, useCurrentRoute } = router
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`createRouter` is built on the same headless layer, so behavior and types stay consistent across both entry points.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Creating Routes
|
|
155
|
+
|
|
156
|
+
Use `defineRoutes` plus `createRoute` instead of hand-written object literals. The `routes` object is the runtime source of truth for names, and `InferRoutes` derives the router type.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const toRoute = createRoute(routes)
|
|
160
|
+
|
|
161
|
+
toRoute({ name: 'home' })
|
|
162
|
+
toRoute({ name: 'detail', params: { id: '42' } })
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
For no-param routes, call `route()` without a type argument. Avoid `route<Record<string, never>>()` because `Record<string, never>` conflicts with the `name` discriminant in intersection types.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Loaders
|
|
170
|
+
|
|
171
|
+
Loaders run before the target route commits.
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
export const router = createRouter<Routes, unknown>({
|
|
175
|
+
initial: toRoute({ name: 'home' }),
|
|
176
|
+
queryClient,
|
|
177
|
+
routeConfig: {
|
|
178
|
+
detail: {
|
|
179
|
+
loader: async ({ params, queryClient }) => {
|
|
180
|
+
await queryClient.ensureQueryData(detailQuery(params.id))
|
|
181
|
+
},
|
|
182
|
+
loaderDeps: ({ params }) => params.id,
|
|
183
|
+
staleTime: 30_000,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Behavior:
|
|
190
|
+
|
|
191
|
+
- The current route remains active while the loader runs.
|
|
192
|
+
- `pendingRoute` tracks the target route immediately.
|
|
193
|
+
- `useRouteLoading({ name: 'detail' })` is true while that route is pending.
|
|
194
|
+
- If the loader throws, the previous route stays mounted and `useRouterError()` returns `{ error, retry }`.
|
|
195
|
+
- `retry()` reruns the full pipeline, including guards.
|
|
196
|
+
|
|
197
|
+
The `queryClient` only needs an `ensureQueryData` method. TanStack Query works, but it is not required by the router.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Guards
|
|
202
|
+
|
|
203
|
+
`beforeLoad` can redirect before a loader runs. In `jotai-iten`, guards receive a Jotai `Getter`, so they can read atoms.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import { authAtom } from './atoms'
|
|
207
|
+
|
|
208
|
+
const router = createRouter<Routes, unknown>({
|
|
209
|
+
initial: toRoute({ name: 'home' }),
|
|
210
|
+
routeConfig: {
|
|
211
|
+
detail: {
|
|
212
|
+
beforeLoad: ({ get }) => {
|
|
213
|
+
if (!get(authAtom).userId) return toRoute({ name: 'home' })
|
|
214
|
+
},
|
|
215
|
+
loader: async ({ params, queryClient }) => {
|
|
216
|
+
await queryClient.ensureQueryData(detailQuery(params.id))
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Redirect loops are capped by `iten-core` and become retryable errors instead of infinite recursion.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Context
|
|
228
|
+
|
|
229
|
+
Use `context` to compute shared values once per navigation. It can be a static object or a function that reads atoms.
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
const router = createRouter<Routes, { userId: string | null }>({
|
|
233
|
+
initial: toRoute({ name: 'home' }),
|
|
234
|
+
context: ({ get }) => ({ userId: get(authAtom).userId }),
|
|
235
|
+
routeConfig: {
|
|
236
|
+
detail: {
|
|
237
|
+
loader: async ({ params, queryClient, context }) => {
|
|
238
|
+
await queryClient.ensureQueryData(detailQuery(params.id, context.userId))
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Components
|
|
248
|
+
|
|
249
|
+
### `<Route>`
|
|
250
|
+
|
|
251
|
+
Renders when its route is active.
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
<Route name="detail">
|
|
255
|
+
{({ id }) => <DetailView id={id} />}
|
|
256
|
+
</Route>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
You can pass a component instead of a render prop:
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
<Route name="detail" component={DetailView} />
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Pending and error states are route-local:
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
<Route
|
|
269
|
+
name="detail"
|
|
270
|
+
pendingComponent={DetailSkeleton}
|
|
271
|
+
errorComponent={({ error, retry }) => (
|
|
272
|
+
<ErrorBanner error={error} onRetry={retry} />
|
|
273
|
+
)}
|
|
274
|
+
>
|
|
275
|
+
{({ id }) => <DetailView id={id} />}
|
|
276
|
+
</Route>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### `<Switch>`
|
|
280
|
+
|
|
281
|
+
Renders the first matching child. Use `<Navigate>` as a fallback.
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
<Switch>
|
|
285
|
+
<Route name="home">{() => <HomeView />}</Route>
|
|
286
|
+
<Route name="detail">{({ id }) => <DetailView id={id} />}</Route>
|
|
287
|
+
<Navigate to={toRoute({ name: 'home' })} />
|
|
288
|
+
</Switch>
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### `<Link>`
|
|
292
|
+
|
|
293
|
+
Typed navigation with route-specific loading state.
|
|
294
|
+
|
|
295
|
+
```tsx
|
|
296
|
+
<Link to={toRoute({ name: 'detail', params: { id: item.id } })}>
|
|
297
|
+
{({ isLoading }) => (isLoading ? 'Loading...' : 'Open')}
|
|
298
|
+
</Link>
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### `<Navigate>`
|
|
302
|
+
|
|
303
|
+
Redirects on mount.
|
|
304
|
+
|
|
305
|
+
```tsx
|
|
306
|
+
{!isAuthenticated && <Navigate to={toRoute({ name: 'home' })} />}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Hooks
|
|
312
|
+
|
|
313
|
+
| Hook | Returns | Use for |
|
|
314
|
+
|---|---|---|
|
|
315
|
+
| `useCurrentRoute()` | `RouteUnion<M> \| null` | Current committed route |
|
|
316
|
+
| `useRoute({ name })` | `{ isActive, params }` | Active checks with narrowed params |
|
|
317
|
+
| `useNavigate()` | `({ target, options }) => Promise<void>` | Programmatic navigation |
|
|
318
|
+
| `useGoBack()` | `() => Promise<void>` | Back navigation without rerunning loaders |
|
|
319
|
+
| `useIsNavigating()` | `boolean` | Global loader/pending indicator |
|
|
320
|
+
| `useCanGoBack()` | `boolean` | History availability |
|
|
321
|
+
| `useRouteLoading({ name })` | `boolean` | Route-specific pending indicator |
|
|
322
|
+
| `useRouterError()` | `RouterError \| null` | Last retryable navigation error |
|
|
323
|
+
|
|
324
|
+
Example:
|
|
325
|
+
|
|
326
|
+
```tsx
|
|
327
|
+
function Header() {
|
|
328
|
+
const navigate = useNavigate()
|
|
329
|
+
const { isActive } = useRoute({ name: 'settings' })
|
|
330
|
+
const isLoading = useRouteLoading({ name: 'settings' })
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
aria-current={isActive ? 'page' : undefined}
|
|
336
|
+
onClick={() => void navigate({ target: toRoute({ name: 'settings' }) })}
|
|
337
|
+
>
|
|
338
|
+
{isLoading ? 'Loading...' : 'Settings'}
|
|
339
|
+
</button>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Type Utilities and Guards
|
|
347
|
+
|
|
348
|
+
These are also available from `jotai-iten/utils` for utility-only imports.
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import {
|
|
352
|
+
createRoute,
|
|
353
|
+
defineRoutes,
|
|
354
|
+
isRoute,
|
|
355
|
+
isRouteName,
|
|
356
|
+
matchRoute,
|
|
357
|
+
type InferRoutes,
|
|
358
|
+
route,
|
|
359
|
+
} from 'jotai-iten/utils'
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### `defineRoutes`
|
|
363
|
+
|
|
364
|
+
Defines the runtime route-name object and derives the compile-time route map.
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
const routes = defineRoutes({
|
|
368
|
+
list: route(),
|
|
369
|
+
detail: route<{ id: string }>(),
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
type Routes = InferRoutes<typeof routes>
|
|
373
|
+
|
|
374
|
+
const toRoute = createRoute(routes)
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### `isRoute`
|
|
378
|
+
|
|
379
|
+
Narrows unknown values by discriminant.
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
function readDetailId(value: unknown) {
|
|
383
|
+
const candidate = { value, name: 'detail' as const }
|
|
384
|
+
if (isRoute<Routes, 'detail'>(candidate)) {
|
|
385
|
+
return candidate.value.id
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### `isRouteName`
|
|
391
|
+
|
|
392
|
+
Useful when decoding host messages or URL-like state.
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
const names = ['home', 'detail', 'settings'] as const
|
|
396
|
+
|
|
397
|
+
const candidateName = { names, value: maybeName }
|
|
398
|
+
|
|
399
|
+
if (isRouteName(candidateName)) {
|
|
400
|
+
candidateName.value // 'home' | 'detail' | 'settings'
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### `matchRoute`
|
|
405
|
+
|
|
406
|
+
Exhaustive branching over the current route.
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
const label = matchRoute<Routes, string>({
|
|
410
|
+
route: currentRoute,
|
|
411
|
+
matcher: {
|
|
412
|
+
home: () => 'Home',
|
|
413
|
+
detail: ({ params }) => `Detail ${params.id}`,
|
|
414
|
+
settings: () => 'Settings',
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
## Multiple Routers
|
|
422
|
+
|
|
423
|
+
Each `createRouter` call creates isolated atoms and components. This is useful for modal stacks or embedded subpanels.
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
const modals = defineRoutes({
|
|
427
|
+
confirm: route<{ message: string; onConfirm: () => void }>(),
|
|
428
|
+
imagePicker: route<{ onSelect: (uri: string) => void }>(),
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
type Modals = InferRoutes<typeof modals>
|
|
432
|
+
|
|
433
|
+
export const modalRoute = createRoute(modals)
|
|
434
|
+
|
|
435
|
+
export const modalRouter = createRouter<Modals, unknown>({
|
|
436
|
+
initial: null,
|
|
437
|
+
})
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## Timing
|
|
443
|
+
|
|
444
|
+
Use `pendingMs` and `pendingMinMs` to avoid flicker.
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
routeConfig: {
|
|
448
|
+
detail: {
|
|
449
|
+
pendingMs: 200,
|
|
450
|
+
pendingMinMs: 100,
|
|
451
|
+
loader: async ({ params, queryClient }) => {
|
|
452
|
+
await queryClient.ensureQueryData(detailQuery(params.id))
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
`pendingMs` delays the visible loading state. `pendingMinMs` keeps it visible long enough to avoid a flash once shown.
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## Advanced Atoms
|
|
463
|
+
|
|
464
|
+
The router exposes raw atoms for advanced Jotai composition.
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
const { atoms } = router
|
|
468
|
+
|
|
469
|
+
// atoms.state
|
|
470
|
+
// atoms.navigate
|
|
471
|
+
// atoms.goBack
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Most apps should prefer hooks/components. Atoms are useful when composing with existing Jotai state modules.
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
## URL Sync
|
|
479
|
+
|
|
480
|
+
`jotai-iten` is in-memory by default. Import `jotai-iten/url` only when the host surface needs URL synchronization. The adapter uses explicit `parse` and `format` functions instead of path-pattern route definitions, so the default router entry stays small.
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
import { createUrlSync } from 'jotai-iten/url'
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Hydrate from the URL when your app starts, then subscribe if the URL should keep following router state:
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
const urlSync = createUrlSync<Routes>({
|
|
490
|
+
router,
|
|
491
|
+
parse: ({ url }) => {
|
|
492
|
+
const id = url.searchParams.get('id')
|
|
493
|
+
return id ? toRoute({ name: 'detail', params: { id } }) : toRoute({ name: 'home' })
|
|
494
|
+
},
|
|
495
|
+
format: ({ route, url }) => {
|
|
496
|
+
const next = new URL(url)
|
|
497
|
+
next.searchParams.set('route', String(route.name))
|
|
498
|
+
if (route.name === 'detail') {
|
|
499
|
+
next.searchParams.set('id', route.id)
|
|
500
|
+
} else {
|
|
501
|
+
next.searchParams.delete('id')
|
|
502
|
+
}
|
|
503
|
+
return next
|
|
504
|
+
},
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
await urlSync.hydrate()
|
|
508
|
+
const stopUrlSync = urlSync.start({ mode: 'replace' })
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Pass custom `getUrl`, `writeUrl`, and `subscribeUrl` functions for tests, embedded hosts, iframe bridges, extension panels, or any environment where the browser History API is not the source of truth.
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## Zod Runtime Validation
|
|
516
|
+
|
|
517
|
+
Zod is a good fit for validating external input: host messages, deep links, persisted state, or URL sync. It is an optional peer used only by `jotai-iten/zod`, so the default router entry stays small.
|
|
518
|
+
|
|
519
|
+
Define schemas once, derive the route union from them, and use a validated route factory in app code:
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
import { createRouter } from 'jotai-iten'
|
|
523
|
+
import {
|
|
524
|
+
createZodRoute,
|
|
525
|
+
defineZodRoutes,
|
|
526
|
+
parseZodRoute,
|
|
527
|
+
zodNoParams,
|
|
528
|
+
type ZodRouteMap,
|
|
529
|
+
} from 'jotai-iten/zod'
|
|
530
|
+
import { z } from 'zod'
|
|
531
|
+
|
|
532
|
+
const schemas = defineZodRoutes({
|
|
533
|
+
home: zodNoParams(),
|
|
534
|
+
detail: z.object({
|
|
535
|
+
id: z.string().min(1),
|
|
536
|
+
tab: z.enum(['summary', 'activity']).default('summary'),
|
|
537
|
+
}),
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
type Routes = ZodRouteMap<typeof schemas>
|
|
541
|
+
|
|
542
|
+
const zodRoute = createZodRoute(schemas)
|
|
543
|
+
|
|
544
|
+
const router = createRouter<Routes, unknown>({
|
|
545
|
+
initial: zodRoute({ name: 'home' }),
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
zodRoute({ name: 'detail', input: { id: '42' } })
|
|
549
|
+
zodRoute({ name: 'detail', input: { id: '42', tab: 'activity' } })
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
Use `parseZodRoute` when the input is unknown:
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
const parsed = parseZodRoute({ schemas, value: hostMessage })
|
|
556
|
+
|
|
557
|
+
if (parsed.success) {
|
|
558
|
+
await navigate({ target: parsed.route })
|
|
559
|
+
}
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
The parser rejects unknown route names, invalid params, and params that try to define their own `name` field.
|
|
563
|
+
|
|
564
|
+
For an end-to-end example, see [`examples/zod`](../../examples/zod).
|
|
565
|
+
|
|
566
|
+
---
|
|
567
|
+
|
|
568
|
+
## Troubleshooting
|
|
569
|
+
|
|
570
|
+
### My no-param route type does not work
|
|
571
|
+
|
|
572
|
+
Use `route()` for no-param routes, not `route<Record<string, never>>()`.
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
const routes = defineRoutes({
|
|
576
|
+
home: route(),
|
|
577
|
+
})
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### My loader does not run again
|
|
581
|
+
|
|
582
|
+
Check `loaderDeps` and `staleTime`. If `staleTime` has not expired for the same dependency key, the loader is skipped.
|
|
583
|
+
|
|
584
|
+
### I need direct imports without components
|
|
585
|
+
|
|
586
|
+
Use:
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
import { createRoute, matchRoute } from 'jotai-iten/utils'
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
### Should I use React Router or TanStack Router instead?
|
|
593
|
+
|
|
594
|
+
Use a URL router when URLs, nested route trees, SSR, route files, or search-param state are central to the app. Use `jotai-iten` when routing is local state and you want small typed primitives.
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
## License
|
|
599
|
+
|
|
600
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
let react = require("react");
|
|
2
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
3
|
+
//#region src/components/Link.tsx
|
|
4
|
+
function makeLink(hooks) {
|
|
5
|
+
const { useNavigate, useRouteLoading } = hooks;
|
|
6
|
+
function Link({ to, children }) {
|
|
7
|
+
const navigate = useNavigate();
|
|
8
|
+
const isLoading = useRouteLoading({ name: to.name });
|
|
9
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
|
|
10
|
+
href: "#",
|
|
11
|
+
onClick: (0, react.useCallback)((e) => {
|
|
12
|
+
e.preventDefault();
|
|
13
|
+
navigate({ target: to });
|
|
14
|
+
}, [navigate, to]),
|
|
15
|
+
children: children({ isLoading })
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return Link;
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
exports.makeLink = makeLink;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { RouteMapDef, RouteUnion } from "iten-core";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/components/Link.d.ts
|
|
5
|
+
type LinkProps<M extends RouteMapDef> = {
|
|
6
|
+
to: RouteUnion<M>;
|
|
7
|
+
children: (state: {
|
|
8
|
+
isLoading: boolean;
|
|
9
|
+
}) => ReactNode;
|
|
10
|
+
};
|
|
11
|
+
type LinkComponent<M extends RouteMapDef> = (props: LinkProps<M>) => ReactNode;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { LinkComponent, LinkProps };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { RouteMapDef, RouteUnion } from "iten-core";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/components/Link.d.ts
|
|
5
|
+
type LinkProps<M extends RouteMapDef> = {
|
|
6
|
+
to: RouteUnion<M>;
|
|
7
|
+
children: (state: {
|
|
8
|
+
isLoading: boolean;
|
|
9
|
+
}) => ReactNode;
|
|
10
|
+
};
|
|
11
|
+
type LinkComponent<M extends RouteMapDef> = (props: LinkProps<M>) => ReactNode;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { LinkComponent, LinkProps };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
//#region src/components/Link.tsx
|
|
4
|
+
function makeLink(hooks) {
|
|
5
|
+
const { useNavigate, useRouteLoading } = hooks;
|
|
6
|
+
function Link({ to, children }) {
|
|
7
|
+
const navigate = useNavigate();
|
|
8
|
+
const isLoading = useRouteLoading({ name: to.name });
|
|
9
|
+
return /* @__PURE__ */ jsx("a", {
|
|
10
|
+
href: "#",
|
|
11
|
+
onClick: useCallback((e) => {
|
|
12
|
+
e.preventDefault();
|
|
13
|
+
navigate({ target: to });
|
|
14
|
+
}, [navigate, to]),
|
|
15
|
+
children: children({ isLoading })
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return Link;
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
export { makeLink };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
let react = require("react");
|
|
2
|
+
//#region src/components/Navigate.tsx
|
|
3
|
+
function makeNavigate(hooks) {
|
|
4
|
+
const { useNavigate } = hooks;
|
|
5
|
+
function Navigate({ to }) {
|
|
6
|
+
const navigate = useNavigate();
|
|
7
|
+
(0, react.useEffect)(() => {
|
|
8
|
+
navigate({ target: to });
|
|
9
|
+
}, []);
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return Navigate;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
exports.makeNavigate = makeNavigate;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { RouteMapDef, RouteUnion } from "iten-core";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/components/Navigate.d.ts
|
|
5
|
+
type NavigateProps<M extends RouteMapDef> = {
|
|
6
|
+
to: RouteUnion<M>;
|
|
7
|
+
};
|
|
8
|
+
type NavigateComponent<M extends RouteMapDef> = (props: NavigateProps<M>) => ReactNode;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { NavigateComponent, NavigateProps };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { RouteMapDef, RouteUnion } from "iten-core";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/components/Navigate.d.ts
|
|
5
|
+
type NavigateProps<M extends RouteMapDef> = {
|
|
6
|
+
to: RouteUnion<M>;
|
|
7
|
+
};
|
|
8
|
+
type NavigateComponent<M extends RouteMapDef> = (props: NavigateProps<M>) => ReactNode;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { NavigateComponent, NavigateProps };
|