@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/DESIGN.md
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# router-kit — design contract
|
|
2
|
+
|
|
3
|
+
`@r0hitsharma/router-kit` ships the route-tree-agnostic parts of a TanStack
|
|
4
|
+
Router setup. This document states what the layer guarantees, what shape each
|
|
5
|
+
dependency takes and why, and what is deliberately absent from v1.
|
|
6
|
+
|
|
7
|
+
## Premise: consumers adopt the router, not a wrapper
|
|
8
|
+
|
|
9
|
+
The app owns its route tree, its router instance, and its `Register` declaration.
|
|
10
|
+
This package never wraps `createRouter`, never exports a route factory, and never
|
|
11
|
+
asks a route to be described twice. What it ships is the handful of pieces that
|
|
12
|
+
are identical in every app and that every app therefore rebuilds — usually
|
|
13
|
+
slightly wrong, and usually in a way no test catches.
|
|
14
|
+
|
|
15
|
+
That framing decides the whole surface. Anything that has to know the shape of a
|
|
16
|
+
specific route tree belongs in the app; anything that would be a verbatim copy
|
|
17
|
+
across apps belongs here.
|
|
18
|
+
|
|
19
|
+
## The four modules and the failure each removes
|
|
20
|
+
|
|
21
|
+
**`search-params.ts`** — `parseSearch` decodes the query string before
|
|
22
|
+
`validateSearch` sees it, and decoding coerces, so a schema written against
|
|
23
|
+
`string` meets numbers, booleans, empty strings, and arrays in production. How
|
|
24
|
+
much it coerces depends on the grammar — the default `parseSearchWith(JSON.parse)`
|
|
25
|
+
coerces strictly more than an identity parser, which is why the specs check both.
|
|
26
|
+
The builders absorb it, and they are total (no input fails, so a route never
|
|
27
|
+
throws on a hand-edited URL) and idempotent under either grammar. Idempotence is
|
|
28
|
+
not text preservation: the default grammar canonicalizes `?v=1e5` to `100000`
|
|
29
|
+
once, and then holds — the grammar's own parse/stringify round trip does that,
|
|
30
|
+
with no redirect involved, which is why the harness pins the settled URL per
|
|
31
|
+
grammar rather than assuming one canonical spelling.
|
|
32
|
+
|
|
33
|
+
**`validated-search.ts`** — the schemas drop what they cannot honour, but the
|
|
34
|
+
address bar keeps it, so a URL advertises state the page is not in. The root
|
|
35
|
+
cleanup replaces the URL with the canonical form. This is the module that
|
|
36
|
+
*consumes* the idempotence contract: it rewrites the URL to the validated result,
|
|
37
|
+
and the result is validated again on arrival.
|
|
38
|
+
|
|
39
|
+
**`table-adapter.ts`** — the design system's table asks for a URL-sync adapter
|
|
40
|
+
and deliberately reads no router. Building one is four lines of obvious code plus
|
|
41
|
+
three non-obvious constraints (memoized identity, per-route key naming, and a
|
|
42
|
+
read that does not normalize), and the non-obvious three are where every
|
|
43
|
+
hand-rolled copy goes wrong.
|
|
44
|
+
|
|
45
|
+
**`testing.ts`** — a redirect chain that does not converge is a hung tab, which
|
|
46
|
+
is not visible from inside the app and has no natural unit test. The harness
|
|
47
|
+
makes it a thrown error, and does the same for a `validateSearch` that rejected:
|
|
48
|
+
the router records that on the match rather than throwing it, so the URL the
|
|
49
|
+
schemas could not validate is exactly the one a naive harness would report as
|
|
50
|
+
clean.
|
|
51
|
+
|
|
52
|
+
The dependency between them is one-directional and worth stating: module 2's
|
|
53
|
+
termination is module 1's idempotence, and module 4 is the executable proof of
|
|
54
|
+
that pairing for a given route tree. That is why all four ship together rather
|
|
55
|
+
than as separate concerns.
|
|
56
|
+
|
|
57
|
+
## Dependency-shape decisions
|
|
58
|
+
|
|
59
|
+
### `@tanstack/react-router` — peer, `^1.170.0`
|
|
60
|
+
|
|
61
|
+
The premise of the package. A bundled copy would mean two module instances and
|
|
62
|
+
therefore two `Register` interfaces, so the app's own route paths and search
|
|
63
|
+
types would not reach this package's helpers; React context is per-copy as well.
|
|
64
|
+
(Not `redirect` identity — `isRedirect` tests `instanceof Response` against the
|
|
65
|
+
global, so a redirect does survive crossing copies. The types do not.) Declared
|
|
66
|
+
as a devDependency at the same line for the test suite, which drives real routers
|
|
67
|
+
headlessly.
|
|
68
|
+
|
|
69
|
+
The floor is the line this package is developed and tested against, and it is
|
|
70
|
+
recorded rather than left open for one specific reason: both `validated-search`
|
|
71
|
+
and `testing` read **`_strictSearch`**, a router-internal field. It is the only
|
|
72
|
+
way to ask a match "what did validation actually apply, with every parent schema
|
|
73
|
+
folded in", and there is no public equivalent. It has been stable across the 1.x
|
|
74
|
+
line, this package's suite fails loudly if it moves, and the caret range means a
|
|
75
|
+
break surfaces on a deliberate upgrade rather than silently.
|
|
76
|
+
|
|
77
|
+
### `zod` — peer, `^4.0.0`
|
|
78
|
+
|
|
79
|
+
The opposite call from `http-client-core`, which keeps zod as a plain
|
|
80
|
+
*dependency*, and the contrast is the argument.
|
|
81
|
+
|
|
82
|
+
There, no zod value crosses the package boundary: validation happens inside, and
|
|
83
|
+
callers see the parsed result. A second copy of zod would be wasteful but
|
|
84
|
+
harmless.
|
|
85
|
+
|
|
86
|
+
Here schemas cross the boundary in both directions. `textParam()` is built by
|
|
87
|
+
this package and composed into `z.object({ ... })` in the app, which is then
|
|
88
|
+
handed to `validateSearch`. Schemas from two copies of zod 4 are not reliably
|
|
89
|
+
interchangeable in that composition — the types are keyed on zod's own internal
|
|
90
|
+
shape — and when they are not, the failure reads as an inscrutable variance error
|
|
91
|
+
at the route definition rather than as a duplicate dependency. A peer makes it
|
|
92
|
+
one install and the question does not arise.
|
|
93
|
+
|
|
94
|
+
### `@r0hitsharma/design-system` — not a dependency at all
|
|
95
|
+
|
|
96
|
+
Not a dependency, not an optional peer. `UrlSyncedTableStateAdapter` is restated
|
|
97
|
+
structurally in `table-adapter.ts` as a four-property interface.
|
|
98
|
+
|
|
99
|
+
A type-only import would be the obvious alternative and is the wrong call, for
|
|
100
|
+
the reason charting's DESIGN.md records for `ChartColorToken`: a type-only import
|
|
101
|
+
of a module this package does not depend on puts an unresolvable reference in the
|
|
102
|
+
published `.d.ts`, and under the `skipLibCheck` that nearly every consumer runs
|
|
103
|
+
that does not fail. It silently widens the type to `any`. An invisible `any` in
|
|
104
|
+
the one seam whose whole job is to match an upstream contract is worse than no
|
|
105
|
+
type at all.
|
|
106
|
+
|
|
107
|
+
An *optional peer* would make the reference resolvable for consumers who install
|
|
108
|
+
the design system and leave it broken for those who do not — the same widening,
|
|
109
|
+
conditional on install state. And unlike charting, this package needs nothing
|
|
110
|
+
from the design system at runtime, so there is no second reason to keep the link.
|
|
111
|
+
|
|
112
|
+
`table-adapter.sync.test.ts` closes the loop: it imports the upstream interface
|
|
113
|
+
from the design system's **source** by relative path and asserts the two types
|
|
114
|
+
are mutually assignable. Two properties of that choice matter:
|
|
115
|
+
|
|
116
|
+
1. It is a source path, not the package specifier, so the test does not wait on
|
|
117
|
+
another package's build — no CI test-job build step is needed for this
|
|
118
|
+
package.
|
|
119
|
+
2. The assertion is type-level, so it is enforced by `npm run type:check`, which
|
|
120
|
+
the lint job runs on **any** `packages/**` change. A property renamed in the
|
|
121
|
+
design system therefore fails CI on the commit that renames it, not on the
|
|
122
|
+
next commit that happens to touch router-kit.
|
|
123
|
+
|
|
124
|
+
### React — no dependency
|
|
125
|
+
|
|
126
|
+
`createUrlSyncedTableAdapter` is a plain factory, not a hook. Nothing in this
|
|
127
|
+
package imports React or renders anything, which is why it builds on the `node`
|
|
128
|
+
tsconfig preset and its suite runs without jsdom.
|
|
129
|
+
|
|
130
|
+
The cost is real and documented at the call site: memoizing the adapter object
|
|
131
|
+
becomes the consumer's obligation, and getting it wrong breaks a debounced search
|
|
132
|
+
in a way that looks like a table bug. A `useUrlSyncedTableSearch` hook would own
|
|
133
|
+
that instead. It is not here because a hook would pull React and the router's
|
|
134
|
+
React bindings into a package whose other three modules need neither, and because
|
|
135
|
+
the memo dependencies are the caller's search object — a value only the caller
|
|
136
|
+
can name. Revisit if a second consumer writes the same `useMemo`.
|
|
137
|
+
|
|
138
|
+
## Deliberate design choices worth recording
|
|
139
|
+
|
|
140
|
+
**The table adapter's read does not reuse `toSearchText`.** It absorbs the same
|
|
141
|
+
decoder coercions — a number or boolean reads as its text, an array or object
|
|
142
|
+
reads as absent — but it carries a string through byte for byte, where
|
|
143
|
+
`toSearchText` trims and degrades empty to absent. Reusing the schema
|
|
144
|
+
normalizer here looked like the obvious economy and is a bug: the adapter's read
|
|
145
|
+
and write are two ends of one loop (the hook renders `searchParam` straight back
|
|
146
|
+
into the search box), so any normalization on the read that the write does not
|
|
147
|
+
apply is a value the user cannot type. `'usd '` would write `?q=usd+`, read back
|
|
148
|
+
`'usd'`, and put the next keystroke on `'usdc'` — a two-word search term becomes
|
|
149
|
+
untypeable, and the table looks broken rather than the URL.
|
|
150
|
+
|
|
151
|
+
The general rule the two modules split along: **normalization is a schema
|
|
152
|
+
concern, applied once at the route boundary; an adapter transports.** A route
|
|
153
|
+
that wants trimming declares `textParam()`, which is visible in the schema and
|
|
154
|
+
can be swapped for a param that keeps whitespace. Trimming inside the adapter
|
|
155
|
+
would be a second copy of that decision with no way to opt out of it.
|
|
156
|
+
|
|
157
|
+
**The cleanup deletes by default, and the escape hatch is an allowlist.** The
|
|
158
|
+
mechanism is subtraction: a key no schema on the route declares is not state, so
|
|
159
|
+
it goes. That is indiscriminate on purpose — the alternative, guessing which
|
|
160
|
+
unknown keys are "probably meaningful", is how a stale `?range=90D` survives —
|
|
161
|
+
but it means a key another system owns is deleted before that system reads it. An
|
|
162
|
+
OAuth `?code=…&state=…` on a callback route is the case this is found through,
|
|
163
|
+
and the symptom (a login that fails with an empty query string) does not point at
|
|
164
|
+
the router.
|
|
165
|
+
|
|
166
|
+
`preserveKeys` is the exemption, and it is an explicit list rather than a
|
|
167
|
+
heuristic for the same reason the deletion is indiscriminate: the app is the only
|
|
168
|
+
thing that knows a key is owned elsewhere, and writing it down is the smallest
|
|
169
|
+
possible way to say so. A listed key is exempt from the comparison as well as
|
|
170
|
+
from the deletion, which is what keeps it from triggering rewrites of its own and
|
|
171
|
+
out of the convergence argument entirely.
|
|
172
|
+
|
|
173
|
+
The one sharp edge, recorded because it cannot be designed away: a listed key
|
|
174
|
+
that a schema *also* declares. Validation still wins for a value it produced, but
|
|
175
|
+
zod drops absent optional keys from its output, so "no schema declares this key"
|
|
176
|
+
and "the schema declared it and rejected the value" are indistinguishable at this
|
|
177
|
+
seam — and in the second case the rejected value survives. Hence the documented
|
|
178
|
+
rule (list only keys no schema declares) rather than a runtime guard that could
|
|
179
|
+
not tell the two apart.
|
|
180
|
+
|
|
181
|
+
**Factories, not bare functions, for the two `beforeLoad` helpers.** Both need
|
|
182
|
+
the app's `stringifySearch`, and a two-argument function cannot be handed
|
|
183
|
+
straight to `beforeLoad`. `createValidatedSearchRedirect({ stringifySearch })`
|
|
184
|
+
produces exactly the `(ctx) => void` the route option wants.
|
|
185
|
+
|
|
186
|
+
**`stringifySearch` is injected rather than derived.** The `beforeLoad` context
|
|
187
|
+
carries no router, so there is no way to read the app's own stringifier. The
|
|
188
|
+
duplication that creates is real, and it is guarded rather than prevented: a
|
|
189
|
+
stringifier that disagrees with the router's produces a redirect target the
|
|
190
|
+
router reads differently, which is a non-convergent chain, which is what
|
|
191
|
+
`settleEntryUrl` throws on.
|
|
192
|
+
|
|
193
|
+
**Context types are declared structurally and widely.** `location.search` is
|
|
194
|
+
`unknown` and the stripper's `search` is `object`, so any route's inferred search
|
|
195
|
+
type is assignable without needing an implicit index signature — an app that
|
|
196
|
+
names its search type with an `interface` would otherwise fail to compile at the
|
|
197
|
+
route definition.
|
|
198
|
+
|
|
199
|
+
**The harness is async.** `resolveEntryUrl` awaits each `beforeLoad`, so the
|
|
200
|
+
public API is `Promise`-returning. That is not a stylistic choice: an `async
|
|
201
|
+
beforeLoad` — the normal shape for an auth guard or a context fetch — throws its
|
|
202
|
+
redirect as a *rejected promise*, which a synchronous `try`/`catch` does not see.
|
|
203
|
+
A sync harness reports the rejected route as the settled one, so the consumer's
|
|
204
|
+
assertion passes while production redirects elsewhere, and the redirect escapes
|
|
205
|
+
as an unattributed unhandled rejection naming neither the route nor the URL.
|
|
206
|
+
Awaiting also subsumes the synchronous case, so both shapes take one path.
|
|
207
|
+
|
|
208
|
+
**The harness throws rather than returning a failure.** It is a test helper; a
|
|
209
|
+
returned error object gets destructured and ignored. A throw cannot be.
|
|
210
|
+
|
|
211
|
+
**The harness does not assert that a hop replaces rather than pushes.** It did,
|
|
212
|
+
and the assertion was unreachable: `followRedirect` — and the `matchRoutes`
|
|
213
|
+
rejection path beside it — spread the thrown redirect's options and then override
|
|
214
|
+
`replace: true`, so a `beforeLoad` redirect always replaces whatever it declared.
|
|
215
|
+
The only way to reach the branch was a hand-built `Response` with fabricated
|
|
216
|
+
`options`, which is the tell: a fixture for a router that does not exist. Worse
|
|
217
|
+
than redundant, the check was wrong in the one direction that matters — a plain
|
|
218
|
+
`throw redirect({ to: '/login' })` leaves `replace` undefined, and the harness
|
|
219
|
+
would have failed it while production replaced. `EntryRedirect.replace` is still
|
|
220
|
+
reported, now documented as the redirect's declared intent rather than as the
|
|
221
|
+
router's behaviour, because a spec pinning what *this package's* helpers write is
|
|
222
|
+
a real assertion.
|
|
223
|
+
|
|
224
|
+
**The harness supplies an `origin`.** Client mode is what makes `beforeLoad` run
|
|
225
|
+
the way a cold load runs it, but the router then reads `window.origin`, and that
|
|
226
|
+
read is a bare global reference that throws a `ReferenceError` in a headless
|
|
227
|
+
runtime rather than yielding `undefined`. A default origin is supplied, and the
|
|
228
|
+
caller's own wins if it set one — otherwise the harness would only work under
|
|
229
|
+
jsdom, which would force every consumer's router spec into a DOM environment for
|
|
230
|
+
no other reason.
|
|
231
|
+
|
|
232
|
+
**The harness forces `scrollRestoration` off.** The same client mode that makes
|
|
233
|
+
`beforeLoad` run like a cold load also arms scroll restoration, from the router
|
|
234
|
+
constructor, through bare `history` and `document` references — a
|
|
235
|
+
`ReferenceError` in a headless runtime, thrown before a single route is matched.
|
|
236
|
+
Since `scrollRestoration: true` is what a real app ships, the harness would have
|
|
237
|
+
been unusable for exactly the consumers it is written for. It is forced rather
|
|
238
|
+
than defaulted because the caller's value is the thing that must not win, and
|
|
239
|
+
nothing is lost: scroll position does not change what a URL means. That is the
|
|
240
|
+
line between the options this harness takes verbatim and the ones it overrides —
|
|
241
|
+
grammar (`parseSearch`, `stringifySearch`, `trailingSlash`, `caseSensitive`,
|
|
242
|
+
`basepath`) is the caller's; browser-only side effects are not.
|
|
243
|
+
|
|
244
|
+
**The harness reports URLs in the router's spelling, not the caller's.** Under a
|
|
245
|
+
`basepath` every URL has two: `/app/items` in the address bar, `/items` inside
|
|
246
|
+
the router. The entry URL arrives in the first, and a `beforeLoad` redirect
|
|
247
|
+
target — built from `location.pathname` — is written in the second, so echoing
|
|
248
|
+
the caller's string back would produce a `hops` array holding both, and a settled
|
|
249
|
+
URL whose spelling depended on which hop happened to produce it. Reporting the
|
|
250
|
+
location the router parsed picks the space both ends agree on, and it is the space
|
|
251
|
+
route paths are written in, so an assertion reads like the route tree.
|
|
252
|
+
|
|
253
|
+
That leaves one hazard worth recording, in the redirect helpers rather than the
|
|
254
|
+
harness: `redirect({ href })` is address-bar space as far as the router is
|
|
255
|
+
concerned — it runs the href through the *input* rewrite before building the
|
|
256
|
+
location it commits — while `createValidatedSearchRedirect` builds its href from
|
|
257
|
+
the router-internal `location.pathname`. The two agree for every basepath that is
|
|
258
|
+
not also a prefix of a route path, because stripping a prefix that is not there
|
|
259
|
+
is a no-op. They disagree for, say, a route at `/app/settings` under a basepath of
|
|
260
|
+
`/app`, where the internal href would be read as the address-bar one and stripped
|
|
261
|
+
to `/settings`. The harness reproduces that faithfully (it feeds the target
|
|
262
|
+
through the same parse the router does) rather than hiding it.
|
|
263
|
+
|
|
264
|
+
**`EntryUrlResolution`'s arms each declare the other's fields as
|
|
265
|
+
optional-`undefined`.** `redirectTo` stays a real discriminant, but a spec can
|
|
266
|
+
read `.replace` or `.routeId` without narrowing first — which is most of what an
|
|
267
|
+
assertion wants to do.
|
|
268
|
+
|
|
269
|
+
## `defaultPreload` is a link feature, and nothing says so
|
|
270
|
+
|
|
271
|
+
Not this package's option, recorded here because it is the kind of failure the
|
|
272
|
+
rest of this document is organized around: configuration that reads as coverage
|
|
273
|
+
while doing nothing.
|
|
274
|
+
|
|
275
|
+
**The precondition: `defaultPreload` has an effect only where the navigation
|
|
276
|
+
control is a `<Link>` — or a custom element built from `useLinkProps` whose
|
|
277
|
+
props are spread onto a rendered DOM node. If an app navigates through
|
|
278
|
+
`useNavigate`, `router.navigate`, or a `<button onClick>`, the option preloads
|
|
279
|
+
nothing, at any value.**
|
|
280
|
+
|
|
281
|
+
That is not a caveat about edge cases; it is the whole implementation.
|
|
282
|
+
`router.options.defaultPreload` is read in exactly one place in
|
|
283
|
+
`@tanstack/react-router` — inside `useLinkProps` — and each mode hangs off
|
|
284
|
+
something only a rendered link has: `'intent'` off the `onMouseEnter`,
|
|
285
|
+
`onFocus`, and `onTouchStart` handlers the hook returns, `'viewport'` off an
|
|
286
|
+
`IntersectionObserver` on the link's own ref, `'render'` off an effect in the
|
|
287
|
+
link component. No link, no call site.
|
|
288
|
+
|
|
289
|
+
The reason it deserves writing down is that every signal available says it
|
|
290
|
+
works. The option is accepted, it typechecks, it survives into
|
|
291
|
+
`router.options`, the devtools show it, and no warning fires — the router's one
|
|
292
|
+
preload warning is about a preload that *failed*, which requires a preload to
|
|
293
|
+
have started. An app can carry `defaultPreload: 'intent'` for its entire life
|
|
294
|
+
and never once preload a route.
|
|
295
|
+
|
|
296
|
+
Checking is cheap and worth doing once: if `<Link` and `useLinkProps` both find
|
|
297
|
+
nothing in the app, `defaultPreload` is dead configuration.
|
|
298
|
+
|
|
299
|
+
**The usual companion setting does not share the precondition.**
|
|
300
|
+
`defaultPreloadStaleTime` — which hands freshness to the loader's own cache
|
|
301
|
+
instead of the router's 30-second default for preloaded matches — is read in
|
|
302
|
+
router-core's loader task on the `preload || match.preload` branch, and *every*
|
|
303
|
+
`router.preloadRoute` call takes it: `preloadRoute` passes `preload: true`
|
|
304
|
+
straight into the lane. That includes the imperative call the next section
|
|
305
|
+
recommends, so the two options come apart exactly where it matters. Measured on
|
|
306
|
+
`@tanstack/react-router` 1.170.32, with a router that renders no `<Link>` at
|
|
307
|
+
all, two `preloadRoute` calls for the same route run the loader **once** at the
|
|
308
|
+
router's default and **twice** at `defaultPreloadStaleTime: 0`.
|
|
309
|
+
|
|
310
|
+
So its precondition is only that something preloads — not that a `<Link>` does.
|
|
311
|
+
It is inert in an app that preloads nothing at all, which is where an app with
|
|
312
|
+
no links starts; it stops being inert the moment that app adopts the workaround
|
|
313
|
+
below, and the 30-second default it overrides is then a real behaviour worth
|
|
314
|
+
choosing deliberately.
|
|
315
|
+
|
|
316
|
+
### What to do when the precondition does not hold
|
|
317
|
+
|
|
318
|
+
Call the imperative API on the event the control already handles.
|
|
319
|
+
`router.preloadRoute` is what `useLinkProps` calls; reaching it directly is
|
|
320
|
+
supported, not a workaround:
|
|
321
|
+
|
|
322
|
+
```tsx
|
|
323
|
+
const router = useRouter();
|
|
324
|
+
|
|
325
|
+
const preloadView = (view: ViewId) => {
|
|
326
|
+
// Rejects if the route's loader throws. `<Link>` swallows that with a console
|
|
327
|
+
// warning rather than letting it escape, and an imperative call has to do the
|
|
328
|
+
// same — an un-caught one surfaces as an unhandled rejection on hover.
|
|
329
|
+
router
|
|
330
|
+
.preloadRoute({ to: '/views/$view', params: { view } })
|
|
331
|
+
.catch(() => {});
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
<button
|
|
335
|
+
onMouseEnter={() => preloadView(view)}
|
|
336
|
+
onFocus={() => preloadView(view)}
|
|
337
|
+
onClick={() => navigate({ to: '/views/$view', params: { view } })}
|
|
338
|
+
>
|
|
339
|
+
{label}
|
|
340
|
+
</button>;
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Pairing `onFocus` with `onMouseEnter` is the part worth copying rather than the
|
|
344
|
+
preload itself: it is what `'intent'` does on a link, and it is what makes the
|
|
345
|
+
behaviour reachable from the keyboard instead of being a mouse-only
|
|
346
|
+
optimization.
|
|
347
|
+
|
|
348
|
+
Leaving `defaultPreload` set alongside this is harmless and honest — it covers
|
|
349
|
+
any `<Link>` the app grows later. Setting it *instead* of this is the failure.
|
|
350
|
+
|
|
351
|
+
## Deliberately out of v1
|
|
352
|
+
|
|
353
|
+
### Loader and query glue
|
|
354
|
+
|
|
355
|
+
The obvious next layer — deriving `loaderDeps`, a query key, and a prefetch from
|
|
356
|
+
one declaration of "which search params this data depends on" — is not here, and
|
|
357
|
+
the reason is that getting its shape wrong is expensive.
|
|
358
|
+
|
|
359
|
+
The failure it would address is real. `loaderDeps` selects which search params a
|
|
360
|
+
loader re-runs for, and a query key restates the same thing for the cache. Add a
|
|
361
|
+
param to the schema, wire it into the component, and forget one of those two
|
|
362
|
+
restatements: the loader serves data for the old param values, the cache serves a
|
|
363
|
+
stale entry under a key that no longer describes it, and nothing errors. The page
|
|
364
|
+
shows confidently wrong numbers. That is the drift worth killing.
|
|
365
|
+
|
|
366
|
+
But a helper that kills it has to know both the router *and* the query layer —
|
|
367
|
+
which key shape `createQueryApi` canonicalizes to, how errors are typed, where
|
|
368
|
+
tags live — so it is not route-tree-agnostic in the way the four modules above
|
|
369
|
+
are. It is the seam between two packages, and its API is the actual design
|
|
370
|
+
problem. v1 has one consumer shape to generalize from, which is not enough to
|
|
371
|
+
tell a good abstraction from a plausible one. Locking a wrong one in propagates
|
|
372
|
+
across every route of every consumer.
|
|
373
|
+
|
|
374
|
+
So: v1 ships the pieces that are provably generic, and the glue waits for a
|
|
375
|
+
second data point. The four modules here are what that glue would be built on
|
|
376
|
+
either way.
|
|
377
|
+
|
|
378
|
+
### Hash-route migration helpers
|
|
379
|
+
|
|
380
|
+
Apps moving off `#/path` routing need an entry-time bridge that rewrites
|
|
381
|
+
`#/items?q=x` into a real path before the router matches, and that bridge is a
|
|
382
|
+
natural sibling of `createSearchParamStripper`.
|
|
383
|
+
|
|
384
|
+
It is out for a different reason: the mapping from old hash routes to new paths
|
|
385
|
+
is entirely app-specific, so the only generic part is the mechanism for
|
|
386
|
+
*expressing* a mapping — which is the whole design. Written without a real
|
|
387
|
+
migration to answer to, it would be a guess at a DSL. Deferred until one exists.
|
|
388
|
+
|
|
389
|
+
### Not planned
|
|
390
|
+
|
|
391
|
+
- **Route-tree factories or a `createAppRouter` wrapper.** Against the premise.
|
|
392
|
+
- **Param builders for domain types** (addresses, chain ids, date ranges). These
|
|
393
|
+
read as generic and are not: each carries a validation rule that belongs to a
|
|
394
|
+
domain, and a wrong rule here would be silently inherited by every consumer.
|
|
395
|
+
`textParam()` composed with a domain refinement in the app is the intended
|
|
396
|
+
path.
|
|
397
|
+
- **A `useSearchParam`-style read hook.** `useSearch` already exists and is typed
|
|
398
|
+
against the app's own route tree, which this package cannot improve on.
|