@r0hitsharma/router-kit 0.12.0-rohit-fork-ci.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/DESIGN.md +398 -0
- package/README.md +422 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/search-params.d.ts +86 -0
- package/dist/search-params.js +118 -0
- package/dist/table-adapter.d.ts +114 -0
- package/dist/table-adapter.js +102 -0
- package/dist/testing.d.ts +149 -0
- package/dist/testing.js +190 -0
- package/dist/validated-search.d.ts +159 -0
- package/dist/validated-search.js +149 -0
- package/package.json +54 -0
- package/src/index.ts +26 -0
- package/src/search-params.test.ts +175 -0
- package/src/search-params.ts +150 -0
- package/src/search-params.types.test.ts +60 -0
- package/src/table-adapter.sync.test.ts +49 -0
- package/src/table-adapter.test.ts +243 -0
- package/src/table-adapter.ts +190 -0
- package/src/table-adapter.types.test.ts +92 -0
- package/src/test-fixtures.ts +415 -0
- package/src/testing.test.ts +479 -0
- package/src/testing.ts +337 -0
- package/src/testing.types.test.ts +67 -0
- package/src/validated-search.test.ts +263 -0
- package/src/validated-search.ts +257 -0
package/README.md
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# @r0hitsharma/router-kit
|
|
2
|
+
|
|
3
|
+
The generic pieces every [TanStack Router](https://tanstack.com/router) app
|
|
4
|
+
rebuilds: search-param schemas that never reject, a root-route cleanup that keeps
|
|
5
|
+
the address bar honest, an adapter for the design system's URL-synced table, and
|
|
6
|
+
a test harness that follows entry URLs to a fixed point.
|
|
7
|
+
|
|
8
|
+
Consumers adopt `@tanstack/react-router` directly — it stays a peer dependency
|
|
9
|
+
and this package never wraps it, never owns your route tree, and never asks you
|
|
10
|
+
to describe a route twice. See [DESIGN.md](./DESIGN.md) for the dependency-shape
|
|
11
|
+
decisions and what is deliberately not here.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @r0hitsharma/router-kit @tanstack/react-router zod
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`@tanstack/react-router` and `zod` are both peer dependencies. See
|
|
20
|
+
[peer-version policy](#peer-version-policy) for what that means for upgrades.
|
|
21
|
+
|
|
22
|
+
## The thing to know first: the query decoder coerces
|
|
23
|
+
|
|
24
|
+
Your `validateSearch` never sees the raw query text — `parseSearch` has already
|
|
25
|
+
decoded it, and decoding rewrites values. So a parser written against `string`
|
|
26
|
+
meets four other shapes in production:
|
|
27
|
+
|
|
28
|
+
| URL | value your parser actually receives |
|
|
29
|
+
| --------------- | ----------------------------------- |
|
|
30
|
+
| `?rows=25` | the number `25` |
|
|
31
|
+
| `?dense=true` | the boolean `true` |
|
|
32
|
+
| `?q=` or `?q` | the empty string `''` |
|
|
33
|
+
| `?q=a&q=b` | the array `['a', 'b']` |
|
|
34
|
+
|
|
35
|
+
This is the single most common source of "the param works in dev and vanishes in
|
|
36
|
+
production" — `?page=2` typed by hand becomes a number, a `z.string()` schema
|
|
37
|
+
rejects it, and the route throws.
|
|
38
|
+
|
|
39
|
+
How much coercion depends on which grammar your router was built with, and both
|
|
40
|
+
common choices coerce:
|
|
41
|
+
|
|
42
|
+
- **`parseSearchWith(JSON.parse)`** — the router's default, so this is what you
|
|
43
|
+
get by not configuring one. It applies the decode above and then `JSON.parse`s
|
|
44
|
+
every value still a string, so `?v=1e5` arrives as `100000`, `?v=null` as
|
|
45
|
+
`null`, and `?v=-0` as `0`.
|
|
46
|
+
- **`parseSearchWith((value) => value)`** — the identity parser, for apps whose
|
|
47
|
+
params are all plain text. It stops after the decode, so those three stay
|
|
48
|
+
strings.
|
|
49
|
+
|
|
50
|
+
A hand-written `parseSearch` *is* the decoder rather than a stage after it, so it
|
|
51
|
+
can opt out — but then it owns the whole grammar.
|
|
52
|
+
|
|
53
|
+
`toSearchText` is idempotent under either: feed its own output back through a
|
|
54
|
+
render-and-redecode round trip and you get that output unchanged. What it does
|
|
55
|
+
**not** promise is byte-preserved URL text — under the default grammar `?v=1e5`
|
|
56
|
+
canonicalizes to `100000`, because the value really was decoded to a number.
|
|
57
|
+
|
|
58
|
+
That rewrite is the *grammar's*, and it costs no redirect: the router
|
|
59
|
+
restringifies the decoded search whenever it builds a location, so its own
|
|
60
|
+
mount-time commit puts `?v=100000` in the address bar. Step 2's rewrites are the
|
|
61
|
+
ones that redirect. Either way it is one rewrite, not a loop; the second pass is
|
|
62
|
+
stable, and step 4 pins the settled form of both under both grammars.
|
|
63
|
+
|
|
64
|
+
`textParam()` and `oneOfParam()` absorb all of it. Reach for `toSearchText` or
|
|
65
|
+
`toSearchOption` directly when a control needs to apply the same rule while
|
|
66
|
+
writing a value back.
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
### 1. Describe the search params
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { oneOfParam, textParam } from '@r0hitsharma/router-kit';
|
|
74
|
+
import { z } from 'zod';
|
|
75
|
+
|
|
76
|
+
export const TABS = ['overview', 'detail'] as const;
|
|
77
|
+
|
|
78
|
+
export const sharedSearchSchema = z.object({
|
|
79
|
+
q: textParam(),
|
|
80
|
+
tab: oneOfParam(TABS),
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`textParam()` trims and treats empty as absent. `oneOfParam(allowed)` keeps a
|
|
85
|
+
value only if it is in the set — pass the set `as const` so the inferred type is
|
|
86
|
+
the literal union rather than `string`.
|
|
87
|
+
|
|
88
|
+
Every builder here is **total** and **idempotent**:
|
|
89
|
+
|
|
90
|
+
- **Total** — no input fails, so `validateSearch` never rejects a URL. A
|
|
91
|
+
hand-edited, stale, or bot-mangled param degrades to absent and the route still
|
|
92
|
+
renders.
|
|
93
|
+
- **Idempotent** — normalizing an already-normalized value, through a full
|
|
94
|
+
render-and-redecode round trip, returns it unchanged.
|
|
95
|
+
|
|
96
|
+
Idempotence is not a nicety; step 2 does not terminate without it.
|
|
97
|
+
|
|
98
|
+
### 2. Make the URL tell the truth
|
|
99
|
+
|
|
100
|
+
The schemas above drop values they cannot honour, but the address bar keeps them.
|
|
101
|
+
So `?range=90D` reads as ninety days of data next to a chart showing the default
|
|
102
|
+
— and that is the URL that gets shared. `createValidatedSearchRedirect` closes
|
|
103
|
+
the gap on the root route: the URL is either the state on screen, or it is
|
|
104
|
+
replaced with the one that is.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { createValidatedSearchRedirect } from '@r0hitsharma/router-kit';
|
|
108
|
+
import {
|
|
109
|
+
createRootRoute,
|
|
110
|
+
createRouter,
|
|
111
|
+
parseSearchWith,
|
|
112
|
+
stringifySearchWith,
|
|
113
|
+
} from '@tanstack/react-router';
|
|
114
|
+
|
|
115
|
+
// Params here are plain text; the default JSON round trip would write
|
|
116
|
+
// `?rows=1` as `?rows=%221%22`.
|
|
117
|
+
const parseSearch = parseSearchWith((value: string) => value);
|
|
118
|
+
const stringifySearch = stringifySearchWith(JSON.stringify);
|
|
119
|
+
|
|
120
|
+
const rootRoute = createRootRoute({
|
|
121
|
+
validateSearch: sharedSearchSchema,
|
|
122
|
+
component: App,
|
|
123
|
+
beforeLoad: createValidatedSearchRedirect({ stringifySearch }),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const router = createRouter({
|
|
127
|
+
routeTree,
|
|
128
|
+
parseSearch,
|
|
129
|
+
stringifySearch,
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Pass the **same** `stringifySearch` to both. It is the one function the helper
|
|
134
|
+
cannot derive, and a mismatch means the cleanup rewrites to a URL the router
|
|
135
|
+
reads differently — which shows up as an infinite redirect. Step 4 is what
|
|
136
|
+
catches that.
|
|
137
|
+
|
|
138
|
+
Two properties, both load-bearing:
|
|
139
|
+
|
|
140
|
+
- **It redirects to an explicit `href`, not `to: '.'`.** A relative target
|
|
141
|
+
resolves against the *pending* location, which during a `beforeLoad` is the
|
|
142
|
+
location being navigated to, not necessarily the one whose params were just
|
|
143
|
+
validated.
|
|
144
|
+
- **It requires total, idempotent schemas.** The rewritten URL is validated
|
|
145
|
+
again on arrival, so a param whose output fails to re-validate to itself
|
|
146
|
+
redirects forever.
|
|
147
|
+
|
|
148
|
+
#### Declare it or lose it
|
|
149
|
+
|
|
150
|
+
Read the mechanism literally: **any key no schema on the route declares is
|
|
151
|
+
deleted from the URL.** That is the feature — a stale `?range=90D` has to go —
|
|
152
|
+
and it does not know what it is deleting. A param some *other* system owns is
|
|
153
|
+
undeclared from this route's point of view, and it goes just as fast.
|
|
154
|
+
|
|
155
|
+
The way this is found in production is an OAuth callback. The provider returns
|
|
156
|
+
the user to `/callback?code=…&state=…`, the root cleanup runs first, and the
|
|
157
|
+
callback route reads an empty query string. The login fails, and nothing in the
|
|
158
|
+
logs mentions the URL. Same shape for a `?utm_source=…` a tag manager reads on
|
|
159
|
+
load, or a `?session_id=…` a payment provider appends.
|
|
160
|
+
|
|
161
|
+
Two ways out, and the first is usually right:
|
|
162
|
+
|
|
163
|
+
1. **Declare the key in a route schema.** Then it is typed, it is part of the
|
|
164
|
+
route's contract, and `useSearch` can read it.
|
|
165
|
+
2. **Allowlist it**, for keys something outside the route tree owns:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
beforeLoad: createValidatedSearchRedirect({
|
|
169
|
+
stringifySearch,
|
|
170
|
+
preserveKeys: ['code', 'state'],
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
An allowlisted key is exempt from the deletion *and* from the comparison, so its
|
|
175
|
+
presence never triggers a rewrite on its own, and it cannot be the param that
|
|
176
|
+
fails to converge. List only keys no schema declares: a listed key never
|
|
177
|
+
overrides a value validation produced, but for a value validation *rejected*
|
|
178
|
+
there is no applied value to lose to, so the rejected one survives in the address
|
|
179
|
+
bar — which is the lie this whole step exists to remove.
|
|
180
|
+
|
|
181
|
+
For the sibling case — a value that moved out of the query string on one branch
|
|
182
|
+
while shared links still carry the old key — `createSearchParamStripper` drops
|
|
183
|
+
one key and carries the rest across:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
const itemDetailRoute = createRoute({
|
|
187
|
+
getParentRoute: () => rootRoute,
|
|
188
|
+
path: '/items/$itemId',
|
|
189
|
+
// The id rides in the path here, so a leftover `?item=` names a second one
|
|
190
|
+
// that nothing reads.
|
|
191
|
+
beforeLoad: createSearchParamStripper('item', { stringifySearch }),
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### 3. Sync a table to the URL
|
|
196
|
+
|
|
197
|
+
`createUrlSyncedTableAdapter` builds the adapter the design system's
|
|
198
|
+
`useUrlSyncedTableStateAdapter` takes, reading two search keys and writing them
|
|
199
|
+
back with replace semantics.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { useUrlSyncedTableStateAdapter } from '@r0hitsharma/design-system';
|
|
203
|
+
import type { UseUrlSyncedTableReturn } from '@r0hitsharma/design-system';
|
|
204
|
+
import { createUrlSyncedTableAdapter } from '@r0hitsharma/router-kit';
|
|
205
|
+
import { useNavigate, useSearch } from '@tanstack/react-router';
|
|
206
|
+
import { useMemo } from 'react';
|
|
207
|
+
|
|
208
|
+
export function useItemsTableUrlState(): UseUrlSyncedTableReturn {
|
|
209
|
+
// Not strict: a table in a drawer can stay mounted on a route where this
|
|
210
|
+
// search does not exist, and both params then read as absent.
|
|
211
|
+
const search = useSearch({ from: '/items', shouldThrow: false });
|
|
212
|
+
const navigate = useNavigate();
|
|
213
|
+
|
|
214
|
+
const adapter = useMemo(
|
|
215
|
+
() =>
|
|
216
|
+
createUrlSyncedTableAdapter({
|
|
217
|
+
search,
|
|
218
|
+
sortKey: 'sort',
|
|
219
|
+
searchKey: 'q',
|
|
220
|
+
navigate,
|
|
221
|
+
}),
|
|
222
|
+
[search, navigate],
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
return useUrlSyncedTableStateAdapter(adapter);
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Four things worth knowing:
|
|
230
|
+
|
|
231
|
+
- **`useMemo` is required, not tidy.** `useUrlSyncedTableStateAdapter` memoizes
|
|
232
|
+
the setters it returns *on the adapter object*, so a fresh object per render
|
|
233
|
+
makes `setGlobalFilter` a new reference every time. A debounced search commit
|
|
234
|
+
holding that reference as a dependency is then torn down and re-armed on every
|
|
235
|
+
unrelated re-render, and never fires under a burst of keystrokes.
|
|
236
|
+
- **`useNavigate()`'s return value goes straight in.** No wrapper: the adapter
|
|
237
|
+
fixes all three navigation fields itself, and types each as the single value it
|
|
238
|
+
takes. It navigates with `to: '.'`, which keeps the current route's path params
|
|
239
|
+
without this package knowing your route tree. A relative target resolves
|
|
240
|
+
against the pending location if there is one, which is why it is wrong in a
|
|
241
|
+
`beforeLoad` (that runs *while* one is pending) and right from an event handler
|
|
242
|
+
(normally none is). The residual case is a write landing inside another
|
|
243
|
+
navigation's microtask window — a debounced search commit racing a link click.
|
|
244
|
+
- **`sortKey` and `searchKey` have no defaults.** Two tables that silently share
|
|
245
|
+
`sort`/`q` leak whichever state was set last across every switch between them,
|
|
246
|
+
and it reads as a bug in the table rather than in the URL.
|
|
247
|
+
- **The read is verbatim; the schema owns trimming.** A string param reaches the
|
|
248
|
+
table exactly as the URL holds it, whitespace included, so a write-read-write
|
|
249
|
+
round trip is lossless. It has to be: the hook renders `searchParam` back into
|
|
250
|
+
the search box, so an adapter that trimmed would make `'usd '` read back as
|
|
251
|
+
`'usd'`, land the next keystroke on `'usdc'`, and leave a two-word term
|
|
252
|
+
untypeable. Trim at the route boundary instead, where it is visible and
|
|
253
|
+
opt-out-able — `textParam()` does exactly that. Values the URL cannot mean (a
|
|
254
|
+
repeated key's array, an object) read as absent, and a number or boolean the
|
|
255
|
+
decoder produced reads as its text.
|
|
256
|
+
|
|
257
|
+
### 4. Prove the entry URLs settle
|
|
258
|
+
|
|
259
|
+
`settleEntryUrl` builds a memory-history router from **your exported
|
|
260
|
+
`router.options`**, follows every `beforeLoad` redirect to a fixed point, and
|
|
261
|
+
throws if the chain never gets there.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { settleEntryUrl } from '@r0hitsharma/router-kit/testing';
|
|
265
|
+
import { describe, expect, it } from 'vitest';
|
|
266
|
+
|
|
267
|
+
import { router } from '../src/router';
|
|
268
|
+
|
|
269
|
+
describe('entry URLs', () => {
|
|
270
|
+
it.each([
|
|
271
|
+
'/',
|
|
272
|
+
'/items?tab=bogus',
|
|
273
|
+
'/items/42?item=7',
|
|
274
|
+
'/unknown/deep/path',
|
|
275
|
+
])('settles %s', async (url) => {
|
|
276
|
+
await expect(settleEntryUrl(router.options, url)).resolves.toBeDefined();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('rewrites a rejected param out of the address bar', async () => {
|
|
280
|
+
const settled = await settleEntryUrl(router.options, '/items?tab=bogus');
|
|
281
|
+
|
|
282
|
+
expect(settled.url).toBe('/items');
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Both entry points are async, so `await` them. That is not incidental: a
|
|
288
|
+
`beforeLoad` is often `async` — an auth guard or a context fetch — and its
|
|
289
|
+
redirect then arrives as a rejected promise rather than a synchronous throw. A
|
|
290
|
+
harness that did not await would report the *rejected* route as the settled one,
|
|
291
|
+
so your spec would pass while production redirected somewhere else.
|
|
292
|
+
|
|
293
|
+
It takes `router.options` rather than a route tree on purpose. `parseSearch`,
|
|
294
|
+
`stringifySearch`, `trailingSlash`, `caseSensitive`, and `basepath` all decide
|
|
295
|
+
what a URL *means*; a harness that rebuilt them would assert against a grammar
|
|
296
|
+
your app does not use — passing while production breaks, or failing on a
|
|
297
|
+
difference that exists only in the test. There is one description of the
|
|
298
|
+
grammar, and it is the one the app runs with.
|
|
299
|
+
|
|
300
|
+
Each throw covers a failure that is invisible from inside the app:
|
|
301
|
+
|
|
302
|
+
- **Non-termination** is a hung tab with a spinning address bar. Here it is a
|
|
303
|
+
thrown error naming the cycle it walked. This is also what catches a
|
|
304
|
+
`stringifySearch` that disagrees with the router's.
|
|
305
|
+
- **A schema that rejected the URL** is a thrown error naming the route and the
|
|
306
|
+
validation message. The router does not throw one: it records the rejection on
|
|
307
|
+
the match and carries on with a search no schema produced. So a harness that
|
|
308
|
+
did not look would report the one URL your schemas *could not* validate as
|
|
309
|
+
settled and clean. Step 1's builders are total, so this fires for a schema
|
|
310
|
+
that is not — a bare `z.number()` on a param, most often.
|
|
311
|
+
|
|
312
|
+
It does **not** assert that a hop replaces rather than pushes, because a
|
|
313
|
+
`beforeLoad` redirect cannot push: every path that follows one overrides
|
|
314
|
+
`replace: true` over the redirect's own options. Such an assertion would fail a
|
|
315
|
+
perfectly good `throw redirect({ to: '/login' })` — `replace` undefined, replaced
|
|
316
|
+
anyway. `resolveEntryUrl` still reports `replace` if you want to pin what your
|
|
317
|
+
own helper *declares*.
|
|
318
|
+
|
|
319
|
+
Two options of yours are overridden rather than honoured, both because a
|
|
320
|
+
headless run cannot mean them: `isServer` is forced false (client mode is what
|
|
321
|
+
makes `beforeLoad` run the way a cold load runs it) and `scrollRestoration` is
|
|
322
|
+
forced off. The second is not cosmetic — the router arms scroll restoration from
|
|
323
|
+
its own constructor via bare `history` and `document` references, so leaving it
|
|
324
|
+
on would throw `ReferenceError: history is not defined` before a route was
|
|
325
|
+
matched, and every app that sets it would find the harness unusable. Neither
|
|
326
|
+
option has any bearing on what a URL means.
|
|
327
|
+
|
|
328
|
+
Under a `basepath` (or a `rewrite`), `settled.url` and `settled.hops` come back
|
|
329
|
+
in the **router's** spelling — `/items`, not `/app/items`. Both spellings of an
|
|
330
|
+
entry URL resolve to the same route, so pass whichever you have; but a redirect
|
|
331
|
+
target built inside a `beforeLoad` is written in the router's space, so reporting
|
|
332
|
+
your string verbatim would put two spellings of one URL in a single `hops` array.
|
|
333
|
+
Assert against your route paths, the way you wrote them.
|
|
334
|
+
|
|
335
|
+
Known limit: the harness supplies a `beforeLoad` with `location`, `matches`,
|
|
336
|
+
`params`, and `search`. A `beforeLoad` that reads route `context` or calls
|
|
337
|
+
`navigate` fails loudly here rather than being silently skipped — entry-time
|
|
338
|
+
redirects do not normally need either, and a loud failure is the point.
|
|
339
|
+
|
|
340
|
+
## Peer-version policy
|
|
341
|
+
|
|
342
|
+
| Peer | Range |
|
|
343
|
+
| ------------------------ | ------------- |
|
|
344
|
+
| `@tanstack/react-router` | `^1.170.0` |
|
|
345
|
+
| `zod` | `^4.0.0` |
|
|
346
|
+
|
|
347
|
+
Both are peers because a second copy of either would break something real:
|
|
348
|
+
|
|
349
|
+
- **The router** carries its types through module augmentation. Your
|
|
350
|
+
`declare module '@tanstack/react-router' { interface Register { ... } }` binds
|
|
351
|
+
to one copy, so a second one leaves your route paths and search types
|
|
352
|
+
unavailable to it. React context is per-copy too, so hooks from one copy return
|
|
353
|
+
nothing inside the other's provider.
|
|
354
|
+
- **zod** schemas built here are composed into `z.object({ ... })` in your app.
|
|
355
|
+
Schemas from two copies are not reliably interchangeable, and when they are
|
|
356
|
+
not, the failure reads as an opaque variance error at the route definition
|
|
357
|
+
rather than as a duplicate dependency.
|
|
358
|
+
|
|
359
|
+
The floor is the line this package is developed and tested against, and the caret
|
|
360
|
+
means router and zod patch/minor upgrades do not need a release here. The one
|
|
361
|
+
thing to know when upgrading the router: the cleanup and the harness both read
|
|
362
|
+
`_strictSearch`, a router-internal field. It is stable in practice, and this
|
|
363
|
+
package's own suite fails loudly if it moves — but that is why the floor is
|
|
364
|
+
recorded rather than left open.
|
|
365
|
+
|
|
366
|
+
`@r0hitsharma/design-system` is **not** a dependency of this package, not
|
|
367
|
+
even an optional peer. The table adapter's type is restated structurally; see
|
|
368
|
+
[DESIGN.md](./DESIGN.md), which ships in the tarball alongside this file.
|
|
369
|
+
|
|
370
|
+
## Exported surface
|
|
371
|
+
|
|
372
|
+
From the root entry:
|
|
373
|
+
|
|
374
|
+
| Export | Kind | Module |
|
|
375
|
+
| --------------------------------- | -------- | -------------------- |
|
|
376
|
+
| `toSearchText` | function | `search-params` |
|
|
377
|
+
| `toSearchOption` | function | `search-params` |
|
|
378
|
+
| `textParam` | function | `search-params` |
|
|
379
|
+
| `oneOfParam` | function | `search-params` |
|
|
380
|
+
| `SearchTextParam` | type | `search-params` |
|
|
381
|
+
| `SearchOptionParam` | type | `search-params` |
|
|
382
|
+
| `createValidatedSearchRedirect` | function | `validated-search` |
|
|
383
|
+
| `createSearchParamStripper` | function | `validated-search` |
|
|
384
|
+
| `rendersSameSearch` | function | `validated-search` |
|
|
385
|
+
| `SearchRecord` | type | `validated-search` |
|
|
386
|
+
| `StringifySearch` | type | `validated-search` |
|
|
387
|
+
| `CanonicalSearchOptions` | type | `validated-search` |
|
|
388
|
+
| `ValidatedSearchRedirectOptions` | type | `validated-search` |
|
|
389
|
+
| `ValidatedSearchContext` | type | `validated-search` |
|
|
390
|
+
| `SearchParamStripperContext` | type | `validated-search` |
|
|
391
|
+
| `createUrlSyncedTableAdapter` | function | `table-adapter` |
|
|
392
|
+
| `UrlSyncedTableStateAdapter` | type | `table-adapter` |
|
|
393
|
+
| `UrlSyncedTableAdapterOptions` | type | `table-adapter` |
|
|
394
|
+
| `UrlSyncedTableNavigate` | type | `table-adapter` |
|
|
395
|
+
| `UrlSyncedTableNavigateOptions` | type | `table-adapter` |
|
|
396
|
+
|
|
397
|
+
From `@r0hitsharma/router-kit/testing`:
|
|
398
|
+
|
|
399
|
+
| Export | Kind |
|
|
400
|
+
| ----------------------- | -------- |
|
|
401
|
+
| `settleEntryUrl` | function |
|
|
402
|
+
| `resolveEntryUrl` | function |
|
|
403
|
+
| `EntryRouterOptions` | type |
|
|
404
|
+
| `EntryUrlResolution` | type |
|
|
405
|
+
| `EntryRedirect` | type |
|
|
406
|
+
| `EntryLeaf` | type |
|
|
407
|
+
| `SettleEntryUrlOptions` | type |
|
|
408
|
+
| `SettledEntryUrl` | type |
|
|
409
|
+
|
|
410
|
+
Both functions return promises — see [step 4](#4-prove-the-entry-urls-settle).
|
|
411
|
+
|
|
412
|
+
## See also
|
|
413
|
+
|
|
414
|
+
Sibling packages are linked by full URL, not by `../`: this file ships in the
|
|
415
|
+
published tarball, where a relative path to another package resolves to nothing.
|
|
416
|
+
|
|
417
|
+
- [design-system](https://github.com/r0hitsharma/uikit/tree/main/packages/design-system)
|
|
418
|
+
for `useUrlSyncedTableStateAdapter` and the `DataTable` this package's adapter
|
|
419
|
+
feeds
|
|
420
|
+
- [http-client-react](https://github.com/r0hitsharma/uikit/tree/main/packages/http-client-react)
|
|
421
|
+
for the TanStack Query layer that a future loader/query glue would have to
|
|
422
|
+
straddle (see [DESIGN.md](./DESIGN.md#loader-and-query-glue))
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { oneOfParam, type SearchOptionParam, type SearchTextParam, textParam, toSearchOption, toSearchText, } from './search-params.js';
|
|
2
|
+
export { createUrlSyncedTableAdapter, type UrlSyncedTableAdapterOptions, type UrlSyncedTableNavigate, type UrlSyncedTableNavigateOptions, type UrlSyncedTableStateAdapter, } from './table-adapter.js';
|
|
3
|
+
export { type CanonicalSearchOptions, createSearchParamStripper, createValidatedSearchRedirect, rendersSameSearch, type SearchParamStripperContext, type SearchRecord, type StringifySearch, type ValidatedSearchContext, type ValidatedSearchRedirectOptions, } from './validated-search.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { oneOfParam, textParam, toSearchOption, toSearchText, } from './search-params.js';
|
|
2
|
+
export { createUrlSyncedTableAdapter, } from './table-adapter.js';
|
|
3
|
+
export { createSearchParamStripper, createValidatedSearchRedirect, rendersSameSearch, } from './validated-search.js';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Collapses a raw URL search value to the text a param means, or `undefined`
|
|
4
|
+
* when it means nothing.
|
|
5
|
+
*
|
|
6
|
+
* ## Why a normalizer is needed at all
|
|
7
|
+
*
|
|
8
|
+
* `validateSearch` never sees the raw query text. `parseSearch` has already
|
|
9
|
+
* decoded it, and decoding coerces spellings, so a parser written against
|
|
10
|
+
* `string` meets four other shapes in production:
|
|
11
|
+
*
|
|
12
|
+
* | URL | value reaching the parser |
|
|
13
|
+
* | ---------------- | ------------------------- |
|
|
14
|
+
* | `?rows=25` | the number `25` |
|
|
15
|
+
* | `?dense=true` | the boolean `true` |
|
|
16
|
+
* | `?q=` or `?q` | the empty string `''` |
|
|
17
|
+
* | `?q=a&q=b` | the array `['a', 'b']` |
|
|
18
|
+
*
|
|
19
|
+
* How much coercion depends on the grammar the router was built with, and both
|
|
20
|
+
* common choices coerce:
|
|
21
|
+
*
|
|
22
|
+
* - `parseSearchWith(JSON.parse)` — the router's **default**. It applies the qss
|
|
23
|
+
* decode above and then `JSON.parse`s every value still a string, so `?v=1e5`
|
|
24
|
+
* arrives as `100000`, `?v=null` as `null`, and `?v=-0` as `0`.
|
|
25
|
+
* - `parseSearchWith((value) => value)` — the identity parser, for apps whose
|
|
26
|
+
* params are plain text. Stops after the qss decode, so those three stay
|
|
27
|
+
* strings.
|
|
28
|
+
*
|
|
29
|
+
* A hand-written `parseSearch` is the decoder rather than a stage after it, so
|
|
30
|
+
* it can opt out entirely — but then it owns the whole grammar.
|
|
31
|
+
*
|
|
32
|
+
* ## What is and is not promised
|
|
33
|
+
*
|
|
34
|
+
* **Promised: idempotence.** Feeding this function's own output back through a
|
|
35
|
+
* render-and-redecode round trip returns that output unchanged, under either
|
|
36
|
+
* grammar above. That is the property the entry-time cleanup needs to terminate.
|
|
37
|
+
*
|
|
38
|
+
* **Not promised: byte-preserved URL text.** Under the default grammar `?v=1e5`
|
|
39
|
+
* canonicalizes to `100000` and `?v=-0` to `0`, because the value really was
|
|
40
|
+
* decoded to a number.
|
|
41
|
+
*
|
|
42
|
+
* That rewrite is the *grammar's*, not the cleanup's, and it costs no redirect:
|
|
43
|
+
* the router restringifies the decoded search whenever it builds a location, and
|
|
44
|
+
* its own mount-time commit is what puts the canonical form in the address bar.
|
|
45
|
+
* The cleanup's rewrites are the ones that show up as a redirect. Either way it
|
|
46
|
+
* is one rewrite and not a loop — the second pass is stable, and
|
|
47
|
+
* `settleEntryUrl` pins the settled form of both under both grammars.
|
|
48
|
+
*/
|
|
49
|
+
export declare function toSearchText(value: unknown): string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Narrows a raw URL value (or a raw control value) to a closed option set.
|
|
52
|
+
* Exported alongside the schema builder because the change handlers that write
|
|
53
|
+
* a param back need the same rule the schema applied when reading it — two
|
|
54
|
+
* spellings of "is this allowed" would be one drift away from a control that
|
|
55
|
+
* can set a value the URL then drops.
|
|
56
|
+
*/
|
|
57
|
+
export declare function toSearchOption<T extends string>(value: unknown, allowed: readonly T[]): T | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Schema shape returned by {@link textParam}. Named so the built `.d.ts` states
|
|
60
|
+
* the param's output type instead of inlining zod's internal generics.
|
|
61
|
+
*/
|
|
62
|
+
export type SearchTextParam = z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodTransform<string | undefined, unknown>>>;
|
|
63
|
+
/**
|
|
64
|
+
* A free-text search param: trimmed, with empty and unusable values degrading
|
|
65
|
+
* to absent. Use for identifiers, query strings, and timestamps — anything
|
|
66
|
+
* whose value set is open.
|
|
67
|
+
*
|
|
68
|
+
* Total and idempotent by construction; see the contract note on this module's
|
|
69
|
+
* builders below.
|
|
70
|
+
*/
|
|
71
|
+
export declare function textParam(): SearchTextParam;
|
|
72
|
+
/**
|
|
73
|
+
* Schema shape returned by {@link oneOfParam}, carrying the closed option set
|
|
74
|
+
* through to the inferred search type.
|
|
75
|
+
*/
|
|
76
|
+
export type SearchOptionParam<T extends string> = z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodTransform<T | undefined, unknown>>>;
|
|
77
|
+
/**
|
|
78
|
+
* A closed-set param: one of `allowed`, or absent. Use for tabs, modes, sort
|
|
79
|
+
* directions, and flags whose spellings are fixed — a hand-edited or stale
|
|
80
|
+
* value degrades to absent rather than reaching a component that has no case
|
|
81
|
+
* for it.
|
|
82
|
+
*
|
|
83
|
+
* Pass `allowed` as `[...] as const` (or a `readonly` tuple) so the inferred
|
|
84
|
+
* search type is the literal union rather than `string`.
|
|
85
|
+
*/
|
|
86
|
+
export declare function oneOfParam<T extends string>(allowed: readonly T[]): SearchOptionParam<T>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Collapses a raw URL search value to the text a param means, or `undefined`
|
|
4
|
+
* when it means nothing.
|
|
5
|
+
*
|
|
6
|
+
* ## Why a normalizer is needed at all
|
|
7
|
+
*
|
|
8
|
+
* `validateSearch` never sees the raw query text. `parseSearch` has already
|
|
9
|
+
* decoded it, and decoding coerces spellings, so a parser written against
|
|
10
|
+
* `string` meets four other shapes in production:
|
|
11
|
+
*
|
|
12
|
+
* | URL | value reaching the parser |
|
|
13
|
+
* | ---------------- | ------------------------- |
|
|
14
|
+
* | `?rows=25` | the number `25` |
|
|
15
|
+
* | `?dense=true` | the boolean `true` |
|
|
16
|
+
* | `?q=` or `?q` | the empty string `''` |
|
|
17
|
+
* | `?q=a&q=b` | the array `['a', 'b']` |
|
|
18
|
+
*
|
|
19
|
+
* How much coercion depends on the grammar the router was built with, and both
|
|
20
|
+
* common choices coerce:
|
|
21
|
+
*
|
|
22
|
+
* - `parseSearchWith(JSON.parse)` — the router's **default**. It applies the qss
|
|
23
|
+
* decode above and then `JSON.parse`s every value still a string, so `?v=1e5`
|
|
24
|
+
* arrives as `100000`, `?v=null` as `null`, and `?v=-0` as `0`.
|
|
25
|
+
* - `parseSearchWith((value) => value)` — the identity parser, for apps whose
|
|
26
|
+
* params are plain text. Stops after the qss decode, so those three stay
|
|
27
|
+
* strings.
|
|
28
|
+
*
|
|
29
|
+
* A hand-written `parseSearch` is the decoder rather than a stage after it, so
|
|
30
|
+
* it can opt out entirely — but then it owns the whole grammar.
|
|
31
|
+
*
|
|
32
|
+
* ## What is and is not promised
|
|
33
|
+
*
|
|
34
|
+
* **Promised: idempotence.** Feeding this function's own output back through a
|
|
35
|
+
* render-and-redecode round trip returns that output unchanged, under either
|
|
36
|
+
* grammar above. That is the property the entry-time cleanup needs to terminate.
|
|
37
|
+
*
|
|
38
|
+
* **Not promised: byte-preserved URL text.** Under the default grammar `?v=1e5`
|
|
39
|
+
* canonicalizes to `100000` and `?v=-0` to `0`, because the value really was
|
|
40
|
+
* decoded to a number.
|
|
41
|
+
*
|
|
42
|
+
* That rewrite is the *grammar's*, not the cleanup's, and it costs no redirect:
|
|
43
|
+
* the router restringifies the decoded search whenever it builds a location, and
|
|
44
|
+
* its own mount-time commit is what puts the canonical form in the address bar.
|
|
45
|
+
* The cleanup's rewrites are the ones that show up as a redirect. Either way it
|
|
46
|
+
* is one rewrite and not a loop — the second pass is stable, and
|
|
47
|
+
* `settleEntryUrl` pins the settled form of both under both grammars.
|
|
48
|
+
*/
|
|
49
|
+
export function toSearchText(value) {
|
|
50
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
51
|
+
return String(value);
|
|
52
|
+
}
|
|
53
|
+
// Arrays (a repeated key), objects, `null`, and absence all mean "no usable
|
|
54
|
+
// value", which the schemas below spell as `undefined`.
|
|
55
|
+
if (typeof value !== 'string') {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
return trimmed === '' ? undefined : trimmed;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Narrows a raw URL value (or a raw control value) to a closed option set.
|
|
63
|
+
* Exported alongside the schema builder because the change handlers that write
|
|
64
|
+
* a param back need the same rule the schema applied when reading it — two
|
|
65
|
+
* spellings of "is this allowed" would be one drift away from a control that
|
|
66
|
+
* can set a value the URL then drops.
|
|
67
|
+
*/
|
|
68
|
+
export function toSearchOption(value, allowed) {
|
|
69
|
+
const text = toSearchText(value);
|
|
70
|
+
if (text === undefined) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
// Widened rather than narrowed: `includes` on a `readonly T[]` would demand a
|
|
74
|
+
// `T` here, which is the very thing being decided. One cast on the way out.
|
|
75
|
+
return allowed.includes(text)
|
|
76
|
+
? text
|
|
77
|
+
: undefined;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A free-text search param: trimmed, with empty and unusable values degrading
|
|
81
|
+
* to absent. Use for identifiers, query strings, and timestamps — anything
|
|
82
|
+
* whose value set is open.
|
|
83
|
+
*
|
|
84
|
+
* Total and idempotent by construction; see the contract note on this module's
|
|
85
|
+
* builders below.
|
|
86
|
+
*/
|
|
87
|
+
export function textParam() {
|
|
88
|
+
return z.optional(z.unknown().transform(toSearchText));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A closed-set param: one of `allowed`, or absent. Use for tabs, modes, sort
|
|
92
|
+
* directions, and flags whose spellings are fixed — a hand-edited or stale
|
|
93
|
+
* value degrades to absent rather than reaching a component that has no case
|
|
94
|
+
* for it.
|
|
95
|
+
*
|
|
96
|
+
* Pass `allowed` as `[...] as const` (or a `readonly` tuple) so the inferred
|
|
97
|
+
* search type is the literal union rather than `string`.
|
|
98
|
+
*/
|
|
99
|
+
export function oneOfParam(allowed) {
|
|
100
|
+
return z.optional(z.unknown().transform((value) => toSearchOption(value, allowed)));
|
|
101
|
+
}
|
|
102
|
+
/*
|
|
103
|
+
* ## Writing a param of your own
|
|
104
|
+
*
|
|
105
|
+
* The total-and-idempotent contract (see `toSearchText`) is what
|
|
106
|
+
* `createValidatedSearchRedirect` needs to terminate, so a custom param has to
|
|
107
|
+
* keep it. Three rules cover it:
|
|
108
|
+
*
|
|
109
|
+
* 1. Build on `toSearchText`, so the decoder's coercions are already absorbed.
|
|
110
|
+
* 2. Keep the transform a pure function of that text — no state, no clock, no
|
|
111
|
+
* counter. A param that grows or varies per call cannot converge.
|
|
112
|
+
* 3. Return absent, or a value that renders to text this module maps back to
|
|
113
|
+
* that same value. Notably `null` is *not* such a value: it is neither
|
|
114
|
+
* dropped as absent nor stable across both grammars.
|
|
115
|
+
*
|
|
116
|
+
* `settleEntryUrl` (from the `/testing` subpath) is the executable check — point
|
|
117
|
+
* it at the real route tree and it fails on a param that does not converge.
|
|
118
|
+
*/
|