@timber-js/app 0.2.0-alpha.89 → 0.2.0-alpha.90

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.
@@ -1 +1 @@
1
- {"version":3,"file":"routing.d.ts","sourceRoot":"","sources":["../../src/plugins/routing.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAC;AAgBlD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AA2D1D,wBAAgB,aAAa,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAmKxD"}
1
+ {"version":3,"file":"routing.d.ts","sourceRoot":"","sources":["../../src/plugins/routing.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAC;AAiBlD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AA0E1D,wBAAgB,aAAa,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAmLxD"}
@@ -1,3 +1,3 @@
1
1
  import { t as classifyUrlSegment } from "../_chunks/segment-classify-BDNn6EzD.js";
2
- import { a as DEFAULT_PAGE_EXTENSIONS, i as scanRoutes, n as generateRouteMap, o as INTERCEPTION_MARKERS, r as classifySegment, t as collectInterceptionRewrites } from "../_chunks/interception-Dpn_UfAD.js";
2
+ import { a as DEFAULT_PAGE_EXTENSIONS, i as scanRoutes, n as generateRouteMap, o as INTERCEPTION_MARKERS, r as classifySegment, t as collectInterceptionRewrites } from "../_chunks/interception-DRlhJWbu.js";
3
3
  export { DEFAULT_PAGE_EXTENSIONS, INTERCEPTION_MARKERS, classifySegment, classifyUrlSegment, collectInterceptionRewrites, generateRouteMap, scanRoutes };
@@ -1,3 +1,4 @@
1
+ import 'virtual:timber-search-params-registry';
1
2
  import { type DebugComponentEntry } from './helpers.js';
2
3
  /**
3
4
  * Set the dev pipeline error handler.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA4DA,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAoCtB;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,mBAAmB,EAAE,KAAK,IAAI,GACtF,IAAI,CAEN;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAE5E;AAqjBD,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAInE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;8BA5SrC,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA8ShD,wBAAiE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AAuBA,OAAO,uCAAuC,CAAC;AA0C/C,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAoCtB;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,mBAAmB,EAAE,KAAK,IAAI,GACtF,IAAI,CAEN;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAE5E;AAqjBD,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAInE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;8BA5SrC,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA8ShD,wBAAiE"}
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * Design docs: 18-build-system.md §"Entry Files", 02-rendering-pipeline.md
13
13
  */
14
+ import 'virtual:timber-search-params-registry';
14
15
  /**
15
16
  * Navigation context passed from the RSC environment to SSR.
16
17
  *
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA8EH;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,iCAAiC;IACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,eAAe,EAAE,OAAO,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACvC;;;0DAGsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;iFAE6E;IAC7E,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;4DACwD;IACxD,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAEjC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE;QACZ,gFAAgF;QAChF,QAAQ,EAAE,MAAM,CAAC;QACjB,uDAAuD;QACvD,OAAO,EAAE,MAAM,CAAC;QAChB,gEAAgE;QAChE,MAAM,EAAE,MAAM,CAAC;QACf,wEAAwE;QACxE,UAAU,EAAE,MAAM,CAAC;QACnB,+CAA+C;QAC/C,OAAO,EAAE,MAAM,CAAC;QAChB,sDAAsD;QACtD,WAAW,EAAE,OAAO,CAAC;KACtB,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,QAAQ,CAAC,CAuLnB;AAED,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAOH,OAAO,uCAAuC,CAAC;AA2E/C;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,iCAAiC;IACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,eAAe,EAAE,OAAO,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACvC;;;0DAGsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;iFAE6E;IAC7E,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;4DACwD;IACxD,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAEjC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE;QACZ,gFAAgF;QAChF,QAAQ,EAAE,MAAM,CAAC;QACjB,uDAAuD;QACvD,OAAO,EAAE,MAAM,CAAC;QAChB,gEAAgE;QAChE,MAAM,EAAE,MAAM,CAAC;QACf,wEAAwE;QACxE,UAAU,EAAE,MAAM,CAAC;QACnB,+CAA+C;QAC/C,OAAO,EAAE,MAAM,CAAC;QAChB,sDAAsD;QACtD,WAAW,EAAE,OAAO,CAAC;KACtB,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,QAAQ,CAAC,CAuLnB;AAED,eAAe,SAAS,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.89",
3
+ "version": "0.2.0-alpha.90",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -110,6 +110,11 @@
110
110
  "publishConfig": {
111
111
  "access": "public"
112
112
  },
113
+ "scripts": {
114
+ "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
115
+ "typecheck": "tsgo --noEmit",
116
+ "prepublishOnly": "pnpm run build"
117
+ },
113
118
  "dependencies": {
114
119
  "@opentelemetry/api": "^1.9.1",
115
120
  "@opentelemetry/context-async-hooks": "^2.6.1",
@@ -152,9 +157,5 @@
152
157
  },
153
158
  "engines": {
154
159
  "node": ">=22.12.0"
155
- },
156
- "scripts": {
157
- "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
158
- "typecheck": "tsgo --noEmit"
159
160
  }
160
- }
161
+ }
package/src/cli.ts CHANGED
File without changes
@@ -29,6 +29,11 @@
29
29
 
30
30
  // @ts-expect-error — virtual module provided by timber-entries plugin
31
31
  import config from 'virtual:timber-config';
32
+ // TIM-830: Populate the search-params registry eagerly so <Link> on the
33
+ // client can serialize flat `Partial<T>` values synchronously during
34
+ // render and click handlers. Side-effect-only import.
35
+ // @ts-expect-error — virtual module provided by timber-routing plugin
36
+ import 'virtual:timber-search-params-registry';
32
37
 
33
38
  import { setClientDeploymentId } from '../rsc-fetch.js';
34
39
  import {
@@ -10,9 +10,6 @@
10
10
  export type { RenderErrorDigest } from './types';
11
11
 
12
12
  // Navigation
13
- import type { JSX } from 'react';
14
- import type { SearchParamsDefinition } from '../search-params/define.js';
15
- import type { LinkBaseProps } from './link';
16
13
  export { Link, interpolateParams, resolveHref, validateLinkHref, buildLinkProps } from './link';
17
14
  export { mergePreservedSearchParams } from '../shared/merge-search-params.js';
18
15
  export type { LinkProps, LinkPropsWithHref, LinkPropsWithParams, LinkBaseProps } from './link';
@@ -20,75 +17,48 @@ export type { LinkSegmentParams, OnNavigateHandler, OnNavigateEvent } from './li
20
17
 
21
18
  // ─── LinkFunction (originally declared here for module augmentation) ───
22
19
  //
23
- // The codegen emits `declare module '@timber-js/app/client' { interface
24
- // LinkFunction { ... } }` to add per-route call signatures. TypeScript
25
- // interface merging via module augmentation only merges with interfaces
26
- // that are ORIGINALLY DECLARED in the augmented module — a re-exported
27
- // type binding (`export type { LinkFunction } from './link'`) creates a
28
- // distinct, unmergeable name. So `LinkFunction` and its supporting
29
- // `ExternalHref` type must live here, not in `./link.tsx`. The `Link`
30
- // const in `./link.tsx` imports this interface as a type-only import.
31
- // See TIM-624.
32
-
33
- /**
34
- * Href types accepted by the catch-all (non-route) call signature.
35
- *
36
- * - External protocols: https://, http://, mailto:, tel:, ftp://
37
- * - Hash-only and query-only links: #section, ?param=value
38
- * - Computed `string` variables (non-literal)
39
- *
40
- * Internal path literals like "/typo-route" do NOT match they must
41
- * be a known route (from codegen) or stored in a `string` variable.
42
- * This catches wrong hrefs at the type level.
43
- */
44
- type ExternalHref =
45
- | `http://${string}`
46
- | `https://${string}`
47
- | `mailto:${string}`
48
- | `tel:${string}`
49
- | `ftp://${string}`
50
- | `//${string}`
51
- | `#${string}`
52
- | `?${string}`;
53
-
54
- /**
55
- * Callable interface for the Link component.
56
- *
57
- * Two kinds of call signatures:
58
- * 1. Per-route (added by codegen via interface merging): DIRECT types
59
- * for segmentParams — preserves TypeScript excess property checking.
60
- * 2. Catch-all (below): accepts external hrefs and computed `string`
61
- * variables. Does NOT accept internal path literals — those must
62
- * match a known route from the codegen.
63
- */
20
+ // The codegen emits TWO augmentations of this interface:
21
+ //
22
+ // 1. Per-route call signatures `href: '/products/[id]'` with direct
23
+ // `segmentParams`/`searchParams` types.
24
+ // 2. Catch-all call signatures `ExternalHref` + computed `string`
25
+ // variable hrefs.
26
+ //
27
+ // Both live in the codegen output (NOT here). Overload ORDER matters
28
+ // for error UX, and TypeScript's declaration-merging rule "later overload
29
+ // sets ordered first" means that whichever augmentation block is
30
+ // declared LATER in the codegen file ends up FIRST in the merged call-
31
+ // signature list and therefore the EARLIER block ends up LAST, which
32
+ // is the overload whose error message TypeScript reports when no
33
+ // overload matches.
34
+ //
35
+ // We want the PER-ROUTE overloads to be the error-reporting ones when
36
+ // a user passes a known literal `href` with a mistyped segmentParam.
37
+ // So the codegen emits them FIRST and the catch-all SECOND. That way
38
+ // a call like:
39
+ //
40
+ // <Link href="/products/[id]" segmentParams={{ id: maybeUndef }} />
41
+ //
42
+ // reports the actual mismatch (`'string | undefined' is not assignable
43
+ // to 'string | number'` on `id`) instead of the notorious "Type 'string'
44
+ // is not assignable to type 'never'" cascade that the old fixed
45
+ // catch-all produced (TIM-832, sibling of TIM-830 / TIM-624).
46
+ //
47
+ // The interface is kept as an empty placeholder here so:
48
+ // - module augmentation has a base interface to merge into
49
+ // - `link.tsx` can `import type { LinkFunction }` and use it as the
50
+ // cast target for the `Link` const (an empty interface accepts any
51
+ // non-nullable value, so the cast is valid even without codegen run)
52
+ // - NO call signatures live here — in a fresh checkout where codegen
53
+ // hasn't run yet, invoking `<Link>` is deliberately a type error,
54
+ // which surfaces "codegen hasn't run" rather than a misleading
55
+ // structural type mismatch.
56
+ //
57
+ // See TIM-624 (original typed Link), TIM-830 (flat searchParams), and
58
+ // TIM-832 (this restructure for overload error UX).
64
59
  export interface LinkFunction {
65
- // External links (literal protocol hrefs)
66
- (
67
- props: LinkBaseProps & {
68
- href: ExternalHref;
69
- segmentParams?: never;
70
- searchParams?: {
71
- definition: SearchParamsDefinition<Record<string, unknown>>;
72
- values: Record<string, unknown>;
73
- };
74
- }
75
- ): JSX.Element;
76
- // Computed/variable href (non-literal string) — e.g. href={myVar}
77
- // `string extends H` is true only when H is the wide `string` type,
78
- // not a specific literal. Template literal hrefs like `/blog/${slug}`
79
- // are handled by resolved-pattern signatures in the codegen.
80
- <H extends string>(
81
- props: string extends H
82
- ? LinkBaseProps & {
83
- href: H;
84
- segmentParams?: Record<string, string | number | string[]>;
85
- searchParams?: {
86
- definition: SearchParamsDefinition<Record<string, unknown>>;
87
- values: Record<string, unknown>;
88
- };
89
- }
90
- : never
91
- ): JSX.Element;
60
+ // intentionally empty call signatures are provided via codegen
61
+ // augmentation in `.timber/timber-routes.d.ts`.
92
62
  }
93
63
  export { usePendingNavigation } from './use-pending-navigation';
94
64
  export { useLinkStatus, LinkStatusContext } from './use-link-status';
@@ -27,6 +27,7 @@ import {
27
27
  type MouseEvent as ReactMouseEvent,
28
28
  } from 'react';
29
29
  import type { SearchParamsDefinition } from '../search-params/define.js';
30
+ import { getSearchParamsDefinition } from '../search-params/registry.js';
30
31
  import type { LinkFunction } from './index.js';
31
32
  import { classifyUrlSegment, type UrlSegment } from '../routing/segment-classify.js';
32
33
  import { LinkStatusContext } from './use-link-status.js';
@@ -128,6 +129,26 @@ export type LinkSegmentParams<T> = {
128
129
  // augmentation merging — only originally-declared interfaces do.
129
130
  // See TIM-624.
130
131
 
132
+ // ─── searchParams prop shapes ────────────────────────────────────
133
+ //
134
+ // Per-route Link overloads (emitted by codegen) pass the flat values shape:
135
+ // searchParams={{ page: 2, q: 'boots' }}
136
+ // The framework looks up the route's SearchParamsDefinition from the
137
+ // search-params registry at runtime (see TIM-830).
138
+ //
139
+ // The catch-all overload in client/index.ts (external/computed hrefs)
140
+ // additionally accepts the legacy wrapped shape:
141
+ // searchParams={{ definition: def, values: { page: 2 } }}
142
+ // because there is no way to look up a definition from a computed string.
143
+ //
144
+ // `resolveHref` discriminates at runtime by presence of a `definition` key.
145
+ type WrappedSearchParamsProp = {
146
+ definition: SearchParamsDefinition<Record<string, unknown>>;
147
+ values: Record<string, unknown>;
148
+ };
149
+ type FlatSearchParamsProp = Record<string, unknown>;
150
+ type LinkSearchParamsProp = WrappedSearchParamsProp | FlatSearchParamsProp;
151
+
131
152
  /**
132
153
  * Runtime-only loose props used internally by the Link implementation.
133
154
  * Not exposed to callers — the public API uses LinkFunction.
@@ -135,20 +156,14 @@ export type LinkSegmentParams<T> = {
135
156
  interface LinkRuntimeProps extends LinkBaseProps {
136
157
  href: string;
137
158
  segmentParams?: Record<string, string | number | string[]>;
138
- searchParams?: {
139
- definition: SearchParamsDefinition<Record<string, unknown>>;
140
- values: Record<string, unknown>;
141
- };
159
+ searchParams?: LinkSearchParamsProp;
142
160
  }
143
161
 
144
162
  // Legacy exports for backward compat (used by buildLinkProps, tests, etc.)
145
163
  export type LinkPropsWithHref = LinkBaseProps & {
146
164
  href: string;
147
165
  segmentParams?: never;
148
- searchParams?: {
149
- definition: SearchParamsDefinition<Record<string, unknown>>;
150
- values: Record<string, unknown>;
151
- };
166
+ searchParams?: LinkSearchParamsProp;
152
167
  };
153
168
  export type LinkPropsWithParams = LinkRuntimeProps & {
154
169
  segmentParams: Record<string, string | number | string[]>;
@@ -296,13 +311,19 @@ export function interpolateParams(
296
311
  * - searchParams serialization via SearchParamsDefinition
297
312
  * - Validation that searchParams and inline query strings are exclusive
298
313
  */
314
+ /**
315
+ * Runtime discriminator: treat `searchParams` as the legacy wrapped shape
316
+ * only when it literally has a `definition` key. Everything else is the
317
+ * flat `Partial<T>` values shape (TIM-830).
318
+ */
319
+ function isWrappedSearchParamsProp(sp: LinkSearchParamsProp): sp is WrappedSearchParamsProp {
320
+ return 'definition' in sp;
321
+ }
322
+
299
323
  export function resolveHref(
300
324
  href: string,
301
325
  params?: Record<string, string | number | string[]>,
302
- searchParams?: {
303
- definition: SearchParamsDefinition<Record<string, unknown>>;
304
- values: Record<string, unknown>;
305
- }
326
+ searchParams?: LinkSearchParamsProp
306
327
  ): string {
307
328
  let resolvedPath = href;
308
329
 
@@ -321,7 +342,36 @@ export function resolveHref(
321
342
  );
322
343
  }
323
344
 
324
- const qs = searchParams.definition.serialize(searchParams.values);
345
+ let definition: SearchParamsDefinition<Record<string, unknown>> | undefined;
346
+ let values: Record<string, unknown>;
347
+
348
+ if (isWrappedSearchParamsProp(searchParams)) {
349
+ // Legacy wrapped shape — used by the catch-all overload for
350
+ // computed/external hrefs where no route lookup is possible.
351
+ definition = searchParams.definition;
352
+ values = searchParams.values;
353
+ } else {
354
+ // Flat shape (TIM-830): look up the definition from the runtime
355
+ // registry using the un-interpolated href pattern (e.g. '/products/[id]').
356
+ // The search-params registry is populated eagerly at startup by the
357
+ // virtual:timber-search-params-registry module generated by the
358
+ // timber-routing Vite plugin.
359
+ definition = getSearchParamsDefinition(href) as
360
+ | SearchParamsDefinition<Record<string, unknown>>
361
+ | undefined;
362
+ values = searchParams;
363
+ if (!definition) {
364
+ throw new Error(
365
+ `<Link> received a flat searchParams object for href "${href}", but no ` +
366
+ `SearchParamsDefinition is registered for that route. ` +
367
+ `Either the route does not export \`searchParams\` from its \`params.ts\`/\`page.tsx\`, ` +
368
+ `or the search-params registry module was not loaded. ` +
369
+ `For external or computed hrefs, pass the legacy \`{ definition, values }\` shape instead.`
370
+ );
371
+ }
372
+ }
373
+
374
+ const qs = definition.serialize(values);
325
375
  if (qs) {
326
376
  resolvedPath = `${resolvedPath}?${qs}`;
327
377
  }
@@ -343,10 +393,7 @@ interface LinkOutputProps {
343
393
  export function buildLinkProps(
344
394
  props: Pick<LinkPropsWithHref, 'href'> & {
345
395
  params?: Record<string, string | number | string[]>;
346
- searchParams?: {
347
- definition: SearchParamsDefinition<Record<string, unknown>>;
348
- values: Record<string, unknown>;
349
- };
396
+ searchParams?: LinkSearchParamsProp;
350
397
  }
351
398
  ): LinkOutputProps {
352
399
  const resolvedHref = resolveHref(props.href, props.params, props.searchParams);
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { Plugin, ViteDevServer } from 'vite';
12
12
  import { writeFile, mkdir } from 'node:fs/promises';
13
+ import { existsSync, readFileSync } from 'node:fs';
13
14
  import { join } from 'node:path';
14
15
  import { scanRoutes } from '../routing/scanner.js';
15
16
  import { generateRouteMap } from '../routing/codegen.js';
@@ -29,6 +30,21 @@ import type { PluginContext } from '../plugin-context.js';
29
30
  const VIRTUAL_MODULE_ID = 'virtual:timber-route-manifest';
30
31
  const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_MODULE_ID}`;
31
32
 
33
+ // TIM-830: Search-params registry virtual module.
34
+ //
35
+ // Statically imports the `searchParams` named export from every page
36
+ // that exports one, and registers each definition into the shared
37
+ // search-params registry keyed by the un-interpolated route pattern
38
+ // (e.g. '/products/[id]'). This lets `<Link>` serialize flat
39
+ // `Partial<T>` values at runtime without callers importing the
40
+ // definition at the call site.
41
+ //
42
+ // The imports are STATIC (`import { searchParams as r0 } from '...'`)
43
+ // so Vite/Rolldown tree-shakes each page's component body out of the
44
+ // registry chunk — only the `searchParams` named export is pulled in.
45
+ const SEARCH_PARAMS_REGISTRY_ID = 'virtual:timber-search-params-registry';
46
+ const RESOLVED_SEARCH_PARAMS_REGISTRY_ID = `\0${SEARCH_PARAMS_REGISTRY_ID}`;
47
+
32
48
  /**
33
49
  * File convention names we track for changes that require manifest regeneration.
34
50
  */
@@ -155,6 +171,14 @@ export function timberRouting(ctx: PluginContext): Plugin {
155
171
  return RESOLVED_VIRTUAL_ID;
156
172
  }
157
173
 
174
+ // TIM-830: Search-params registry virtual module
175
+ if (
176
+ cleanId === SEARCH_PARAMS_REGISTRY_ID ||
177
+ cleanId.endsWith(`/${SEARCH_PARAMS_REGISTRY_ID}`)
178
+ ) {
179
+ return RESOLVED_SEARCH_PARAMS_REGISTRY_ID;
180
+ }
181
+
158
182
  return null;
159
183
  },
160
184
 
@@ -165,14 +189,22 @@ export function timberRouting(ctx: PluginContext): Plugin {
165
189
  * with absolute import paths for all file references.
166
190
  */
167
191
  load(id: string) {
168
- if (id !== RESOLVED_VIRTUAL_ID) return null;
192
+ if (id === RESOLVED_VIRTUAL_ID) {
193
+ // If routeTree hasn't been built yet (shouldn't happen), scan now
194
+ if (!ctx.routeTree) {
195
+ rescan();
196
+ }
197
+ return generateManifestModule(ctx.routeTree!, ctx.root);
198
+ }
169
199
 
170
- // If routeTree hasn't been built yet (shouldn't happen), scan now
171
- if (!ctx.routeTree) {
172
- rescan();
200
+ if (id === RESOLVED_SEARCH_PARAMS_REGISTRY_ID) {
201
+ if (!ctx.routeTree) {
202
+ rescan();
203
+ }
204
+ return generateSearchParamsRegistryModule(ctx.routeTree!);
173
205
  }
174
206
 
175
- return generateManifestModule(ctx.routeTree!, ctx.root);
207
+ return null;
176
208
  },
177
209
 
178
210
  /**
@@ -259,9 +291,11 @@ function invalidateManifest(server: ViteDevServer): void {
259
291
  const env = server.environments[envName];
260
292
  if (!env?.moduleGraph) continue;
261
293
 
262
- const mod = env.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID);
263
- if (mod) {
264
- env.moduleGraph.invalidateModule(mod);
294
+ for (const virtualId of [RESOLVED_VIRTUAL_ID, RESOLVED_SEARCH_PARAMS_REGISTRY_ID]) {
295
+ const mod = env.moduleGraph.getModuleById(virtualId);
296
+ if (mod) {
297
+ env.moduleGraph.invalidateModule(mod);
298
+ }
265
299
  }
266
300
  }
267
301
 
@@ -269,6 +303,81 @@ function invalidateManifest(server: ViteDevServer): void {
269
303
  server.hot.send({ type: 'full-reload' });
270
304
  }
271
305
 
306
+ /**
307
+ * TIM-830: Walk the route tree and collect every page that exports a
308
+ * `searchParams` definition, then emit a module that statically imports
309
+ * each export and registers it into the shared search-params registry.
310
+ *
311
+ * Keys use the un-interpolated route pattern (e.g. '/products/[id]') so
312
+ * <Link> can look up the definition from the original `href` prop before
313
+ * segmentParams are interpolated.
314
+ */
315
+ function generateSearchParamsRegistryModule(tree: RouteTree): string {
316
+ const entries: Array<{ urlPath: string; filePath: string }> = [];
317
+
318
+ function walk(node: SegmentNode): void {
319
+ // params.ts is the canonical location for `export const searchParams`;
320
+ // page.tsx is the legacy fallback (see codegen.ts `collectRoutes`).
321
+ if (node.page) {
322
+ if (node.params && fileHasSearchParamsExport(node.params.filePath)) {
323
+ entries.push({ urlPath: node.urlPath, filePath: node.params.filePath });
324
+ } else if (fileHasSearchParamsExport(node.page.filePath)) {
325
+ entries.push({ urlPath: node.urlPath, filePath: node.page.filePath });
326
+ }
327
+ }
328
+ for (const child of node.children) walk(child);
329
+ for (const [, slot] of node.slots) walk(slot);
330
+ }
331
+ walk(tree.root);
332
+
333
+ // Deterministic order for stable output / snapshots
334
+ entries.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
335
+
336
+ const lines: string[] = [];
337
+ lines.push('// Auto-generated search-params registry — do not edit.');
338
+ lines.push('// Generated by timber-routing plugin (TIM-830).');
339
+ lines.push('');
340
+ lines.push("import { registerSearchParams } from '@timber-js/app/search-params';");
341
+ lines.push('');
342
+
343
+ // Static imports — Vite/Rolldown tree-shakes the page component body out
344
+ // of this chunk, leaving only the `searchParams` named export.
345
+ entries.forEach((entry, i) => {
346
+ lines.push(`import { searchParams as r${i} } from ${JSON.stringify(entry.filePath)};`);
347
+ });
348
+ lines.push('');
349
+
350
+ entries.forEach((entry, i) => {
351
+ lines.push(`registerSearchParams(${JSON.stringify(entry.urlPath)}, r${i});`);
352
+ });
353
+ lines.push('');
354
+
355
+ // Exporting an empty object keeps the module a real ESM module even
356
+ // when there are zero entries (no top-level imports in that case).
357
+ lines.push('export {};');
358
+ lines.push('');
359
+
360
+ return lines.join('\n');
361
+ }
362
+
363
+ /**
364
+ * Lightweight check for a `searchParams` named export. Mirrors
365
+ * `fileHasExport('searchParams')` in routing/codegen.ts so we stay
366
+ * in sync with what the type codegen detects.
367
+ */
368
+ function fileHasSearchParamsExport(filePath: string): boolean {
369
+ try {
370
+ if (!existsSync(filePath)) return false;
371
+ const content = readFileSync(filePath, 'utf-8');
372
+ return (
373
+ /export\s+(const|let|var)\s+searchParams\b/.test(content) ||
374
+ /export\s*\{[^}]*\bsearchParams\b[^}]*\}/.test(content)
375
+ );
376
+ } catch {
377
+ return false;
378
+ }
379
+ }
380
+
272
381
  /**
273
382
  * Generate the virtual module source code for the route manifest.
274
383
  *