@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 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.