@timber-js/app 0.2.0-alpha.188 → 0.2.0-alpha.189

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.
Files changed (42) hide show
  1. package/dist/_chunks/{resolve-schema-5ma5pp1b.js → resolve-schema-CBR6Lm4i.js} +2 -2
  2. package/dist/_chunks/{resolve-schema-5ma5pp1b.js.map → resolve-schema-CBR6Lm4i.js.map} +1 -1
  3. package/dist/_chunks/{schema-bridge-Cc2Gngu1.js → schema-bridge-C83xa9lT.js} +2 -2
  4. package/dist/_chunks/{schema-bridge-Cc2Gngu1.js.map → schema-bridge-C83xa9lT.js.map} +1 -1
  5. package/dist/_chunks/{use-query-states-BbU5Ge1V.js → use-query-states-I3JMng6J.js} +29 -5
  6. package/dist/_chunks/use-query-states-I3JMng6J.js.map +1 -0
  7. package/dist/client/index.js +1 -1
  8. package/dist/client/internal.js +1 -1
  9. package/dist/client/use-query-states.d.ts.map +1 -1
  10. package/dist/codec.js +1 -1
  11. package/dist/cookies/index.js +1 -1
  12. package/dist/params/index.js +1 -1
  13. package/dist/schema-bridge.d.ts +4 -1
  14. package/dist/schema-bridge.d.ts.map +1 -1
  15. package/dist/search-params/define.d.ts +30 -4
  16. package/dist/search-params/define.d.ts.map +1 -1
  17. package/dist/search-params/index.d.ts +1 -1
  18. package/dist/search-params/index.d.ts.map +1 -1
  19. package/dist/search-params/index.js +20 -5
  20. package/dist/search-params/index.js.map +1 -1
  21. package/dist/search-params/parse-total.d.ts +14 -3
  22. package/dist/search-params/parse-total.d.ts.map +1 -1
  23. package/dist/search-params/serialize-equal.d.ts +9 -0
  24. package/dist/search-params/serialize-equal.d.ts.map +1 -0
  25. package/dist/server/internal.js +1 -1
  26. package/dist/server/internal.js.map +1 -1
  27. package/dist/server/route-element-builder.d.ts.map +1 -1
  28. package/dist/server/slot-resolver.d.ts +12 -0
  29. package/dist/server/slot-resolver.d.ts.map +1 -1
  30. package/docs/api/33-api-search-params.mdx +3 -3
  31. package/docs/learn/05-typed-params.mdx +1 -1
  32. package/package.json +1 -1
  33. package/src/client/use-query-states.ts +23 -14
  34. package/src/schema-bridge.ts +8 -3
  35. package/src/search-params/define.ts +73 -14
  36. package/src/search-params/index.ts +1 -0
  37. package/src/search-params/parse-total.ts +17 -4
  38. package/src/search-params/serialize-equal.ts +14 -0
  39. package/src/search-params/wrappers.ts +1 -1
  40. package/src/server/route-element-builder.ts +11 -1
  41. package/src/server/slot-resolver.ts +82 -0
  42. package/dist/_chunks/use-query-states-BbU5Ge1V.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAS9D,OAAO,EAA8C,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAGpG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAQhE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,EAAqB,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC;AA+C/E;;;;;;;;GAQG;AACH;;;;;;;;;;;;GAYG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,UAAU,GAChB,MAAM,EAAE,CAGV;AAED,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,OAAO,CAM7D;AAID;;;GAGG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAID,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,OAAO,EAAE,cAAc,CAAC;IACxB,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B;;;;OAIG;IACH,YAAY,EAAE,aAAa,EAAE,CAAC;CAC/B;AA8DD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI,EACxC,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC3C,OAAO,CAAC,kBAAkB,CAAC,CAuiB7B"}
1
+ {"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAS9D,OAAO,EAIL,KAAK,aAAa,EACnB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAQhE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,EAAqB,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC;AA+C/E;;;;;;;;GAQG;AACH;;;;;;;;;;;;GAYG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,UAAU,GAChB,MAAM,EAAE,CAGV;AAED,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,OAAO,CAM7D;AAID;;;GAGG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAID,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,OAAO,EAAE,cAAc,CAAC;IACxB,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B;;;;OAIG;IACH,YAAY,EAAE,aAAa,EAAE,CAAC;CAC/B;AA8DD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI,EACxC,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC3C,OAAO,CAAC,kBAAkB,CAAC,CA4iB7B"}
@@ -127,6 +127,18 @@ export declare function slotUrlParts(segment: ManifestSegmentNode, match: RouteM
127
127
  * @internal
128
128
  */
129
129
  export declare function publishSlotSegmentParams(segment: ManifestSegmentNode, match: RouteMatch, destinationUrl: string, parentTreePath: string | undefined): void;
130
+ /**
131
+ * Emit slot metadata for a layout whose rendering was SKIPPED.
132
+ *
133
+ * Skipped layouts never reach `resolveSlotProps`, but the client rebuilds
134
+ * its segment tree from each navigation's X-Timber-Segments response. If
135
+ * skipped slots are absent, the client loses their contentKeys and can't
136
+ * advertise them on the next navigation — breaking key-based slot skipping.
137
+ *
138
+ * This computes contentKey and isRequestDependent for each slot without
139
+ * rendering anything, using the same derivation as `resolveSlotProps`.
140
+ */
141
+ export declare function emitSlotInfoForSkippedLayout(segment: ManifestSegmentNode, segmentId: string, match: RouteMatch, destinationUrl: string, slotSkipInfo: SlotSkipEntry[]): void;
130
142
  interface ResolveSlotPropsArgs {
131
143
  segment: ManifestSegmentNode;
132
144
  segmentId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"slot-resolver.d.ts","sourceRoot":"","sources":["../../src/server/slot-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAGrE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAO9D,OAAO,EAAkB,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAI5E,KAAK,eAAe,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,KAAK,CAAC,YAAY,CAAC;AAgClE;;;;;;;;;;;;GAYG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,UAAU,EACjB,CAAC,EAAE,eAAe,EAClB,YAAY,CAAC,EAAE,mBAAmB,EAClC,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAAC,KAAK,CAAC,YAAY,GAAG,IAAI,CAAC,CAoIpC;AAsKD,sEAAsE;AACtE,MAAM,WAAW,eAAe;IAC9B,yCAAyC;IACzC,IAAI,EAAE,WAAW,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAAC;IAC/C,+EAA+E;IAC/E,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAC7B,0FAA0F;IAC1F,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;CAC/C;AAED;;;;;;;;;;;;;;GAcG;AACH;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,mBAAmB,EAC7B,QAAQ,EAAE,mBAAmB,EAAE,GAC9B,MAAM,CAYR;AA+BD,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,kBAAkB,EAAE,OAAO,CAAC;IAC5B,0DAA0D;IAC1D,MAAM,EAAE,OAAO,CAAC;IAChB,gFAAgF;IAChF,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,mBAAmB,EAC5B,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,MAAM,GACV,MAAM,EAAE,CAUV;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,mBAAmB,EAC5B,KAAK,EAAE,UAAU,EACjB,cAAc,EAAE,MAAM,EACtB,cAAc,EAAE,MAAM,GAAG,SAAS,GACjC,IAAI,CAcN;AAED,UAAU,oBAAoB;IAC5B,OAAO,EAAE,mBAAmB,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,UAAU,CAAC;IAClB,CAAC,EAAE,eAAe,CAAC;IACnB,YAAY,CAAC,EAAE,mBAAmB,CAAC;IACnC,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,eAAe,GAAG,IAAI,CAAC;IACxC,YAAY,EAAE,aAAa,EAAE,CAAC;CAC/B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,gBAAgB,CAAC,EACrC,OAAO,EACP,SAAS,EACT,KAAK,EACL,CAAC,EACD,YAAY,EACZ,cAAc,EACd,cAAc,EACd,eAAe,EACf,YAAY,GACb,EAAE,oBAAoB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAwIzD"}
1
+ {"version":3,"file":"slot-resolver.d.ts","sourceRoot":"","sources":["../../src/server/slot-resolver.ts"],"names":[],"mappings":"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"}
@@ -108,7 +108,7 @@ The codec protocol shared across search params, segment params, and cookies. See
108
108
  ```ts
109
109
  interface SearchParamCodec<T> {
110
110
  parse(value: string | string[] | undefined): T;
111
- serialize(value: T): string | null;
111
+ serialize(value: T): string | string[] | null;
112
112
  urlKey?: string;
113
113
  // optional total entry point — preferred over parse when present
114
114
  parseServerSide?(value: string | string[] | undefined): T;
@@ -141,11 +141,11 @@ So `?page=1&page=2` gives `1`, `parseAsBoolean` on a missing `?debug=` gives `nu
141
141
 
142
142
  A present value is not validated for you — each parser decides. `parseAsBoolean` on `?v=maybe` is `false`, and `parseAsInteger` on `?v=12abc` is `12`. Use a schema when you need the value rejected rather than coerced.
143
143
 
144
- The exception is `parseAsNativeArrayOf`, nuqs's *multi*-value parser for repeated keys. It is **not usable in `defineSearchParams`**: its `parse` is declared over `readonly string[]`, so the field is a compile error, and forcing it through leaves the client hook reading it as empty. Use an array schema for repeated keys. Tracked as TIM-1355.
144
+ `parseAsNativeArrayOf` is nuqs's *multi*-value parser for repeated keys (`?tags=a&tags=b`). It works in `defineSearchParams`: timber accepts it via `TotalSearchParamCodec` (its `parseServerSide` covers the full domain) and its `serialize` returns `string[]`, which `buildSearchParams` emits as repeated keys. The codec round-trips correctly.
145
145
 
146
146
  Two consequences worth knowing:
147
147
 
148
- - **`parseAsArrayOf` reads one key, not repeated keys.** It is a comma-separated list inside a single value, so `?tags=a,b` gives `['a','b']` but `?tags=a&tags=b` gives `['a']`. For repeated keys use an array schema — `tags: z.array(z.string()).default([])` reads both `?tags=a` and `?tags=a&tags=b`.
148
+ - **`parseAsArrayOf` reads one key, not repeated keys.** It is a comma-separated list inside a single value, so `?tags=a,b` gives `['a','b']` but `?tags=a&tags=b` gives `['a']`. For repeated keys use `parseAsNativeArrayOf` or an array schema — `tags: z.array(z.string()).default([])`.
149
149
  - **`parseAsJson` requires a validator argument** — `parseAsJson(z.object({ … }).parse)`. Called bare it does not type-check, and at runtime returns `null` for every value.
150
150
 
151
151
  A field typed by a bare parser is nullable (`parseAsString` → `string | null`). Use `withDefault()` — timber's or the parser's own — to make it non-nullable.
@@ -15,7 +15,7 @@ A codec converts between string values and typed values:
15
15
  ```ts
16
16
  interface Codec<T> {
17
17
  parse(value: string | string[] | undefined): T;
18
- serialize(value: T): string | null;
18
+ serialize(value: T): string | string[] | null; // string[] for repeated keys
19
19
  }
20
20
  ```
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.188",
3
+ "version": "0.2.0-alpha.189",
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",
@@ -19,6 +19,7 @@ import type {
19
19
  QueryStatesOptions,
20
20
  } from '../search-params/define.js';
21
21
  import { parseTotal } from '../search-params/parse-total.js';
22
+ import { serializedEqual } from '../search-params/serialize-equal.js';
22
23
 
23
24
  // ─── Codec Bridge ─────────────────────────────────────────────────
24
25
 
@@ -113,22 +114,28 @@ function bridgeCodec<T>(codec: SearchParamCodec<T>): MultiParser<T> & { defaultV
113
114
  wrapNuqsValue(parseTotal(codec, v.length === 1 ? v[0] : [...v])),
114
115
  serialize: (v: unknown) => {
115
116
  const value = unwrapNuqsValue(v);
116
- // Delegate null to the codec — some codecs encode null as a real
117
- // query value. undefined has no encoding; nuqs requires a string.
118
- //
119
- // ONE element, always. A multi parser may return several — nuqs
120
- // appends one key per entry — but `Codec.serialize` returns a single
121
- // string by construction, so timber cannot emit repeated keys here
122
- // any more than `buildSearchParams` can. Widening that protocol is
123
- // TIM-1353. Wrapping the same string keeps the URL the hook writes
124
- // byte-identical to the one `buildSearchParams` writes.
125
- return [value === undefined ? '' : (codec.serialize(value as T) ?? '')];
117
+ if (value === undefined) return [''];
118
+ // TIM-1354: catch TypeError from codecs whose serialize is not total
119
+ // over null (e.g. parseAsIsoDate.serialize(null) → null.toISOString()).
120
+ // Only TypeError — deliberate signals must not be swallowed.
121
+ let result: string | string[] | null;
122
+ try {
123
+ result = codec.serialize(value as T);
124
+ } catch (error) {
125
+ if (error instanceof TypeError) return [''];
126
+ throw error;
127
+ }
128
+ // TIM-1353: pass through string[] from codecs that emit repeated
129
+ // keys. nuqs multi parsers append one key=value per array element.
130
+ if (Array.isArray(result)) return result;
131
+ return [result ?? ''];
126
132
  },
127
133
  eq: (a: unknown, b: unknown) => {
128
134
  if (a === b) return true;
129
135
  try {
130
- return (
131
- codec.serialize(unwrapNuqsValue(a) as T) === codec.serialize(unwrapNuqsValue(b) as T)
136
+ return serializedEqual(
137
+ codec.serialize(unwrapNuqsValue(a) as T),
138
+ codec.serialize(unwrapNuqsValue(b) as T)
132
139
  );
133
140
  } catch {
134
141
  return false;
@@ -275,9 +282,11 @@ export function useQueryStates<T extends Record<string, unknown>>(
275
282
  if (forwarded === partial) forwarded = { ...partial };
276
283
  forwarded[key] = null;
277
284
  } else if (value === null) {
278
- let encoded: string | null = null;
285
+ let encoded: string | string[] | null = null;
279
286
  try {
280
- encoded = codecs[key as keyof T]?.serialize(null as T[keyof T]) ?? null;
287
+ const raw = codecs[key as keyof T]?.serialize(null as T[keyof T]);
288
+ // string[] is non-null, but an empty array carries no value
289
+ encoded = raw === null ? null : Array.isArray(raw) ? (raw.length > 0 ? raw : null) : raw;
281
290
  } catch {
282
291
  // Codec can't serialize null — treat as a deletion.
283
292
  }
@@ -358,16 +358,21 @@ export function fromCookieSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
358
358
  * It also serializes an empty array to `null` (omitting the key) where
359
359
  * `fromSchema` writes `''`.
360
360
  */
361
- export function fromArraySchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
361
+ export function fromArraySchema<T>(schema: StandardSchemaV1<T>): {
362
+ parse(value: string | string[] | undefined): T;
363
+ serialize(value: T): string | string[] | null;
364
+ } {
362
365
  return {
363
366
  parse: (value: string | string[] | undefined): T =>
364
367
  parseThroughSchema(schema, arrayFirst, value),
365
- serialize(value: T): string | null {
368
+ serialize(value: T): string | string[] | null {
366
369
  if (value === null || value === undefined) {
367
370
  return null;
368
371
  }
372
+ // TIM-1353: return string[] so buildSearchParams emits repeated keys
373
+ // (?tag=a&tag=b) instead of comma-joining into one key (?tag=a%2Cb).
369
374
  if (Array.isArray(value)) {
370
- return value.length === 0 ? null : value.join(',');
375
+ return value.length === 0 ? null : value.map(String);
371
376
  }
372
377
  return String(value);
373
378
  },
@@ -21,6 +21,7 @@ import type { Codec } from '../codec.js';
21
21
  // `nuqs` into every server entry (TIM-1298). See shared/als-slots.ts.
22
22
  import { getSearchParamsFromAls } from '../shared/als-slots.js';
23
23
  import { parseTotal } from './parse-total.js';
24
+ import { serializedEqual } from './serialize-equal.js';
24
25
 
25
26
  // ---------------------------------------------------------------------------
26
27
  // Types
@@ -45,7 +46,19 @@ import { parseTotal } from './parse-total.js';
45
46
  * by defineSearchParams and wrapped via fromSchema; those ARE total
46
47
  * through `parse` and are called that way.
47
48
  */
48
- export interface SearchParamCodec<T> extends Codec<T> {
49
+ export interface SearchParamCodec<T> extends Omit<Codec<T>, 'serialize'> {
50
+ /**
51
+ * Typed value → URL string(s). Return `null` to omit/clear.
52
+ *
53
+ * A codec MAY return `string[]` to emit repeated keys (`?tag=a&tag=b`).
54
+ * `buildSearchParams` appends one `key=value` entry per array element;
55
+ * the nuqs bridge forwards the array to nuqs, which does the same.
56
+ * Codecs that always produce a single value return a plain string.
57
+ *
58
+ * Wider than `Codec<T>.serialize` (`string | null`) because search params
59
+ * have a repeated-key concept that cookies and segment params do not.
60
+ */
61
+ serialize(value: T): string | string[] | null;
49
62
  /** Optional URL key alias, set by withUrlKey(). */
50
63
  urlKey?: string;
51
64
  /**
@@ -136,7 +149,7 @@ export interface SearchParamsDefinition<T extends Record<string, unknown>> {
136
149
  useQueryStates(options?: QueryStatesOptions): [T, SetParams<T>];
137
150
 
138
151
  /** Extend with additional codecs or Standard Schema objects. */
139
- extend<U extends Record<string, SearchParamCodec<unknown> | StandardSchemaV1<unknown>>>(
152
+ extend<U extends Record<string, SearchParamField>>(
140
153
  codecs: U
141
154
  ): SearchParamsDefinition<T & { [K in keyof U]: InferField<U[K]> }>;
142
155
 
@@ -235,8 +248,27 @@ export type InferField<V> = V extends {
235
248
  : T | undefined
236
249
  : never;
237
250
 
238
- /** Acceptable field value for defineSearchParams: a codec or a Standard Schema. */
239
- export type SearchParamField<T = unknown> = SearchParamCodec<T> | StandardSchemaV1<T>;
251
+ /**
252
+ * A codec whose total entry point is `parseServerSide`, even when `parse`
253
+ * has a narrower signature. nuqs multi parsers (`parseAsNativeArrayOf`) have
254
+ * `parse(value: readonly string[])` — too narrow for `SearchParamCodec`'s
255
+ * `parse(string | string[] | undefined)` — but their `parseServerSide`
256
+ * covers the full domain. Timber never calls `parse` directly on these;
257
+ * `parseTotal` always reaches `parseServerSide` first (TIM-1355).
258
+ */
259
+ export interface TotalSearchParamCodec<T> {
260
+ parseServerSide(value: string | string[] | undefined): T;
261
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
262
+ parse(...args: any[]): any;
263
+ serialize(value: T): string | string[] | null;
264
+ urlKey?: string;
265
+ }
266
+
267
+ /** Acceptable field value for defineSearchParams: a codec, a total codec, or a Standard Schema. */
268
+ export type SearchParamField<T = unknown> =
269
+ | SearchParamCodec<T>
270
+ | TotalSearchParamCodec<T>
271
+ | StandardSchemaV1<T>;
240
272
 
241
273
  // ---------------------------------------------------------------------------
242
274
  // Internal helpers
@@ -291,7 +323,7 @@ function normalizeRaw(
291
323
  * which for a nuqs parser is `T | null` while its `serialize` declares
292
324
  * `T`. Widening to `unknown` states that honestly instead of casting.
293
325
  */
294
- function getDefaultSerialized(codec: SearchParamCodec<unknown>): string | null {
326
+ function getDefaultSerialized(codec: SearchParamCodec<unknown>): string | string[] | null {
295
327
  try {
296
328
  const absent = parseTotal(codec, undefined);
297
329
  return absent === null || absent === undefined ? null : codec.serialize(absent);
@@ -305,6 +337,12 @@ function getDefaultSerialized(codec: SearchParamCodec<unknown>): string | null {
305
337
  /**
306
338
  * Resolve a field value to a SearchParamCodec. Auto-detects Standard Schema
307
339
  * objects and wraps them with fromSchema. Reads .urlKey from codecs.
340
+ *
341
+ * The returned codec is typed as `SearchParamCodec<unknown>` because the
342
+ * codec map uses that type. At runtime, the value may be a `Codec<T>` from
343
+ * `fromSchema` (whose serialize returns `string | null`) or a nuqs parser
344
+ * (whose serialize returns `string | string[] | null`). Both are safe
345
+ * because `buildSearchParams` handles the union.
308
346
  */
309
347
  function resolveField(
310
348
  fieldName: string,
@@ -312,7 +350,8 @@ function resolveField(
312
350
  ): { codec: SearchParamCodec<unknown>; urlKey?: string } {
313
351
  // Check for codec first (codecs may also have '~standard' if they're nuqs parsers)
314
352
  if (isCodec(value)) {
315
- return { codec: value, urlKey: value.urlKey };
353
+ const urlKey = (value as SearchParamCodec<unknown>).urlKey;
354
+ return { codec: value as SearchParamCodec<unknown>, urlKey };
316
355
  }
317
356
 
318
357
  // Auto-detect Standard Schema. Schemas that reject undefined input and
@@ -320,7 +359,7 @@ function resolveField(
320
359
  // for absent params, and InferField widens the output type to
321
360
  // T | undefined. design/23-search-params.md §"Implicit Optionality"
322
361
  if (isStandardSchema(value)) {
323
- return { codec: fromSchema(value) };
362
+ return { codec: fromSchema(value) as SearchParamCodec<unknown> };
324
363
  }
325
364
 
326
365
  throw new Error(
@@ -443,7 +482,7 @@ function buildDefinition<T extends Record<string, unknown>>(
443
482
  urlKeys: Record<string, string>
444
483
  ): SearchParamsDefinition<T> {
445
484
  // Pre-compute default serialized values for omission check
446
- const defaultSerialized: Record<string, string | null> = {};
485
+ const defaultSerialized: Record<string, string | string[] | null> = {};
447
486
  for (const key of Object.keys(codecMap)) {
448
487
  defaultSerialized[key] = getDefaultSerialized(codecMap[key as keyof T]);
449
488
  }
@@ -497,13 +536,33 @@ function buildDefinition<T extends Record<string, unknown>>(
497
536
  for (const prop of Object.keys(codecMap)) {
498
537
  if (!(prop in values)) continue;
499
538
  const codec = codecMap[prop as keyof T] as SearchParamCodec<unknown>;
500
- const serialized = codec.serialize(values[prop as keyof T] as unknown);
501
539
 
502
- // Omit if serialized value matches the default
503
- if (serialized === defaultSerialized[prop]) continue;
504
- if (serialized === null) continue;
540
+ // TIM-1354: serialize may not be total over null — nuqs parsers like
541
+ // parseAsIsoDate throw TypeError on null (null.toISOString()). Catch
542
+ // TypeError and omit, matching what setParams does on the client.
543
+ // Only TypeError — a deliberate throw (redirect(), notFound()) from a
544
+ // codec's serialize must propagate, not be silently swallowed.
545
+ let serialized: string | string[] | null;
546
+ try {
547
+ serialized = codec.serialize(values[prop as keyof T] as unknown);
548
+ } catch (error) {
549
+ if (error instanceof TypeError) continue;
550
+ throw error;
551
+ }
505
552
 
506
- parts.push(`${encodeURIComponent(getUrlKey(prop))}=${encodeURIComponent(serialized)}`);
553
+ if (serialized === null) continue;
554
+ // Omit if serialized value matches the default
555
+ if (serializedEqual(serialized, defaultSerialized[prop])) continue;
556
+
557
+ // TIM-1353: string[] → repeated keys (?tag=a&tag=b)
558
+ const urlKey = encodeURIComponent(getUrlKey(prop));
559
+ if (Array.isArray(serialized)) {
560
+ for (const entry of serialized) {
561
+ parts.push(`${urlKey}=${encodeURIComponent(entry)}`);
562
+ }
563
+ } else {
564
+ parts.push(`${urlKey}=${encodeURIComponent(serialized)}`);
565
+ }
507
566
  }
508
567
 
509
568
  return parts.join('&');
@@ -516,7 +575,7 @@ function buildDefinition<T extends Record<string, unknown>>(
516
575
  }
517
576
 
518
577
  // ---- extend ----
519
- function extend<U extends Record<string, SearchParamCodec<unknown> | StandardSchemaV1<unknown>>>(
578
+ function extend<U extends Record<string, SearchParamField>>(
520
579
  newCodecs: U
521
580
  ): SearchParamsDefinition<T & { [K in keyof U]: InferField<U[K]> }> {
522
581
  type Combined = T & { [K in keyof U]: InferField<U[K]> };
@@ -7,6 +7,7 @@
7
7
  export type {
8
8
  SearchParamCodec,
9
9
  SearchParamCodecWithUrlKey,
10
+ TotalSearchParamCodec,
10
11
  InferCodec,
11
12
  InferField,
12
13
  CodecMap,
@@ -43,12 +43,23 @@
43
43
  * Design doc: design/23-search-params.md §"nuqs parsers, made total"
44
44
  */
45
45
 
46
- import type { Codec } from '../codec.js';
46
+ /**
47
+ * Minimal interface for anything `parseTotal` can dispatch. Only `parse` is
48
+ * required; `parseServerSide` is the optional total entry point. `serialize`
49
+ * is deliberately absent — `parseTotal` never calls it, and requiring it
50
+ * would prevent `SearchParamCodec<T>` (whose serialize returns `string |
51
+ * string[] | null`) from being passed where `Codec<T>` (whose serialize
52
+ * returns `string | null`) is expected.
53
+ */
54
+ export interface ParseableCodec<T> {
55
+ parse(value: string | string[] | undefined): T;
56
+ parseServerSide?(value: string | string[] | undefined): T;
57
+ }
47
58
 
48
59
  /**
49
60
  * A codec that publishes a total entry point over the raw URL domain.
50
61
  *
51
- * The return is `T`, not `T | null`: this is the entry point timber calls,
62
+ * The return is `T`, not `T | null`. This is the entry point timber calls,
52
63
  * so whatever it answers IS the field's type. A `null` for an absent param
53
64
  * belongs in `T` — a bare nuqs parser is a codec of `string | null`, and
54
65
  * `parseAsInteger.withDefault(1)` is a codec of `number`, because nuqs
@@ -60,7 +71,9 @@ export interface TotalCodec<T> {
60
71
  parseServerSide(value: string | string[] | undefined): T;
61
72
  }
62
73
 
63
- function hasParseServerSide<T>(codec: Codec<T>): codec is Codec<T> & TotalCodec<T> {
74
+ function hasParseServerSide<T>(
75
+ codec: ParseableCodec<T>
76
+ ): codec is ParseableCodec<T> & TotalCodec<T> {
64
77
  return typeof (codec as Partial<TotalCodec<T>>).parseServerSide === 'function';
65
78
  }
66
79
 
@@ -73,6 +86,6 @@ function hasParseServerSide<T>(codec: Codec<T>): codec is Codec<T> & TotalCodec<
73
86
  * widen it, and a caller that must handle "no value" (`withDefault`) sees
74
87
  * it because `T` carries it.
75
88
  */
76
- export function parseTotal<T>(codec: Codec<T>, raw: string | string[] | undefined): T {
89
+ export function parseTotal<T>(codec: ParseableCodec<T>, raw: string | string[] | undefined): T {
77
90
  return hasParseServerSide(codec) ? codec.parseServerSide(raw) : codec.parse(raw);
78
91
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Compare two serialize results for equality. Needed because `string[]`
3
+ * from repeated-key codecs does not compare by reference.
4
+ *
5
+ * Shared between define.ts (buildSearchParams default-omission) and
6
+ * use-query-states.ts (bridgeCodec eq). One source of truth.
7
+ */
8
+ export function serializedEqual(a: string | string[] | null, b: string | string[] | null): boolean {
9
+ if (a === b) return true;
10
+ if (Array.isArray(a) && Array.isArray(b)) {
11
+ return a.length === b.length && a.every((v, i) => v === b[i]);
12
+ }
13
+ return false;
14
+ }
@@ -75,7 +75,7 @@ export function withDefault<T>(
75
75
  const result = parseTotal(codec, value);
76
76
  return result === null || result === undefined ? defaultValue : result;
77
77
  },
78
- serialize(value: NonNullable<T>): string | null {
78
+ serialize(value: NonNullable<T>): string | string[] | null {
79
79
  return codec.serialize(value);
80
80
  },
81
81
  };
@@ -28,7 +28,12 @@ import { requestContextAls } from './als-registry.js';
28
28
  import { PageDenyBoundary, buildDenyPageChain, handleCaughtDeny } from './deny-boundary.js';
29
29
  import type { DenyPageEntry } from './deny-boundary.js';
30
30
  import { LAYOUT_CHILDREN_SLOT } from './prebuilt/slots.js';
31
- import { resolveSlotProps, publishSlotSegmentParams, type SlotSkipEntry } from './slot-resolver.js';
31
+ import {
32
+ resolveSlotProps,
33
+ publishSlotSegmentParams,
34
+ emitSlotInfoForSkippedLayout,
35
+ type SlotSkipEntry,
36
+ } from './slot-resolver.js';
32
37
  import { consumedUrlParts } from './chain-url-parts.js';
33
38
  import { withPublishedParams } from './publish-params.js';
34
39
  import type { RscPayloadRoot } from '../shared/payload-root.js';
@@ -571,6 +576,11 @@ export async function buildRouteElement(
571
576
  // because an unchanged slot is what made this layout skippable.
572
577
  publishSlotSegmentParams(segment, match, destinationUrl, segmentTreePaths[i]);
573
578
 
579
+ // Emit slot metadata so the client's segment tree retains contentKeys
580
+ // for slots of skipped layouts. Without this, the client rebuilds its
581
+ // tree from the response and loses slot keys it can't advertise next time.
582
+ emitSlotInfoForSkippedLayout(segment, segmentKeys[i], match, destinationUrl, slotSkipInfo);
583
+
574
584
  // No error boundaries are wrapped for a skipped segment — the `continue`
575
585
  // below returns before that walk. A deny addressed to this segment
576
586
  // therefore finds no boundary in the payload, and since TIM-1356 the
@@ -561,6 +561,88 @@ export function publishSlotSegmentParams(
561
561
  }
562
562
  }
563
563
 
564
+ /**
565
+ * Emit slot metadata for a layout whose rendering was SKIPPED.
566
+ *
567
+ * Skipped layouts never reach `resolveSlotProps`, but the client rebuilds
568
+ * its segment tree from each navigation's X-Timber-Segments response. If
569
+ * skipped slots are absent, the client loses their contentKeys and can't
570
+ * advertise them on the next navigation — breaking key-based slot skipping.
571
+ *
572
+ * This computes contentKey and isRequestDependent for each slot without
573
+ * rendering anything, using the same derivation as `resolveSlotProps`.
574
+ */
575
+ export function emitSlotInfoForSkippedLayout(
576
+ segment: ManifestSegmentNode,
577
+ segmentId: string,
578
+ match: RouteMatch,
579
+ destinationUrl: string,
580
+ slotSkipInfo: SlotSkipEntry[]
581
+ ): void {
582
+ const slotEntries = Object.entries(segment.slots ?? {});
583
+ if (slotEntries.length === 0) return;
584
+
585
+ const destinationParts = slotUrlParts(segment, match, destinationUrl);
586
+ const ownerParts = consumedPartsThroughSegment(segment, match);
587
+
588
+ for (const [slotName, slotNode] of slotEntries) {
589
+ const slotManifest = slotNode as ManifestSegmentNode;
590
+ const slotKey = computeSlotKey(segmentId, `@${slotName}`);
591
+ const destMatch = matchUrlParts(slotManifest, destinationParts);
592
+
593
+ const destLeaf = destMatch?.chain[destMatch.chain.length - 1];
594
+ const entryFile = destLeaf?.page?.filePath ?? null;
595
+ const contentKey = computeSlotContentKey(
596
+ slotKey,
597
+ ownerParts,
598
+ entryFile,
599
+ destMatch?.params ?? {}
600
+ );
601
+
602
+ const hasAccessInChain =
603
+ !!slotManifest.access || (destMatch?.chain.some((seg) => seg.access) ?? false);
604
+
605
+ let slotRequestDep = hasAccessInChain;
606
+ if (!slotRequestDep) {
607
+ const hasRenderedPage = destMatch && destLeaf?.page;
608
+ if (hasRenderedPage) {
609
+ if (
610
+ slotManifest.layout?.filePath &&
611
+ isStaticRequestDependent(slotManifest.layout.filePath)
612
+ ) {
613
+ slotRequestDep = true;
614
+ }
615
+ if (!slotRequestDep) {
616
+ for (const seg of destMatch.chain) {
617
+ if (seg.layout?.filePath && isStaticRequestDependent(seg.layout.filePath)) {
618
+ slotRequestDep = true;
619
+ break;
620
+ }
621
+ }
622
+ }
623
+ if (!slotRequestDep && destLeaf?.page?.filePath) {
624
+ slotRequestDep = isStaticRequestDependent(destLeaf.page.filePath);
625
+ }
626
+ } else {
627
+ const defaultFile = slotManifest.default?.filePath;
628
+ if (!defaultFile) {
629
+ slotRequestDep = true;
630
+ } else {
631
+ slotRequestDep = isStaticRequestDependent(defaultFile);
632
+ }
633
+ }
634
+ }
635
+
636
+ slotSkipInfo.push({
637
+ slotKey,
638
+ parentSegmentId: segmentId,
639
+ isRequestDependent: slotRequestDep,
640
+ denied: false,
641
+ contentKey,
642
+ });
643
+ }
644
+ }
645
+
564
646
  interface ResolveSlotPropsArgs {
565
647
  segment: ManifestSegmentNode;
566
648
  segmentId: string;
@@ -1 +0,0 @@
1
- {"version":3,"file":"use-query-states-BbU5Ge1V.js","names":[],"sources":["../../src/search-params/parse-total.ts","../../src/client/use-query-states.ts"],"sourcesContent":["/**\n * parseTotal — invoke a codec over the FULL raw domain.\n *\n * `SearchParamCodec.parse` is documented to be total over\n * `string | string[] | undefined`, because that is exactly what a URL\n * hands it: a param can be absent (`undefined`) or repeated (`string[]`).\n * Timber's own codecs and the Standard Schema bridges honour that.\n *\n * nuqs parsers do not. Their `parse` expects a **present scalar string** —\n * nuqs checks presence itself before ever calling it — so `parseAsBoolean`\n * and `parseAsIsoDate` threw a render-phase 500 on an absent param and\n * `parseAsString` returned an array on a repeated one (TIM-1350).\n *\n * nuqs ships the missing adapter: every parser builder exposes\n * `parseServerSide(value: string | string[] | undefined)`, which maps\n * absent → `null` (or the parser's `withDefault` value), takes the FIRST\n * entry of a repeated param (matching `URLSearchParams.get()`), and wraps\n * the inner `parse` so a throw becomes `null`. That is precisely timber's\n * domain, so we call it in preference to `parse` rather than hand-rolling\n * a second normalization that could disagree with the client hook.\n *\n * Feature detection, not an instanceof check: any codec MAY publish\n * `parseServerSide` to declare \"this is my total entry point\" — it is an\n * optional member of `SearchParamCodec` — and a codec that does not is\n * assumed already total and called through `parse`. Timber codecs and\n * schema bridges take the second branch untouched; several of them rely on\n * `parse(undefined)` to produce their default.\n *\n * **Search params only.** Segment params (`server/param-coercion.ts`) call\n * `codec.parse` directly and must keep doing so: their domain is a value\n * the router matched, never absent, and a codec that REJECTS one is how a\n * route produces a 404. Routing a rejection through nuqs's `safeParse`\n * would turn that 404 into a silent `null` param. The two domains differ\n * in what \"no value\" means, not just in plumbing. Cookies are a third\n * domain (`cookies/define-cookie.ts`) and are likewise untouched.\n *\n * `parseServerSide` carries a `@deprecated` tag in nuqs (it steers users to\n * loaders, which timber does not use). It remains public, typed and\n * exercised; `tests/nuqs-codec-boundary.test.ts` asserts totality for every\n * parser through timber's own API, so a nuqs release that drops it fails\n * loudly rather than silently reinstating the 500s.\n *\n * Design doc: design/23-search-params.md §\"nuqs parsers, made total\"\n */\n\nimport type { Codec } from '../codec.js';\n\n/**\n * A codec that publishes a total entry point over the raw URL domain.\n *\n * The return is `T`, not `T | null`: this is the entry point timber calls,\n * so whatever it answers IS the field's type. A `null` for an absent param\n * belongs in `T` — a bare nuqs parser is a codec of `string | null`, and\n * `parseAsInteger.withDefault(1)` is a codec of `number`, because nuqs\n * narrows its own `parseServerSide` return to `NonNullable<T>`. Declaring\n * `T | null` here would let a codec annotated `SearchParamCodec<string>`\n * hand back `null` under a non-nullable type (TIM-1350 review).\n */\nexport interface TotalCodec<T> {\n parseServerSide(value: string | string[] | undefined): T;\n}\n\nfunction hasParseServerSide<T>(codec: Codec<T>): codec is Codec<T> & TotalCodec<T> {\n return typeof (codec as Partial<TotalCodec<T>>).parseServerSide === 'function';\n}\n\n/**\n * Parse a raw URL value through a codec, using the codec's total entry\n * point when it publishes one.\n *\n * Returns `T`, from both branches. A `null` for an absent param is part of\n * the codec's own `T` — see TotalCodec above — so this signature does not\n * widen it, and a caller that must handle \"no value\" (`withDefault`) sees\n * it because `T` carries it.\n */\nexport function parseTotal<T>(codec: Codec<T>, raw: string | string[] | undefined): T {\n return hasParseServerSide(codec) ? codec.parseServerSide(raw) : codec.parse(raw);\n}\n","/**\n * useQueryStates — client-side hook for URL-synced search params.\n *\n * Delegates to nuqs for URL synchronization, batching, React 19 transitions,\n * and throttled URL writes. Bridges timber's SearchParamCodec protocol to\n * nuqs-compatible parsers.\n *\n * Design doc: design/23-search-params.md §\"Codec Bridge\"\n */\n\n'use client';\n\nimport { useQueryStates as nuqsUseQueryStates } from 'nuqs';\nimport type { MultiParser } from 'nuqs';\nimport type {\n SearchParamCodec,\n SearchParamsDefinition,\n SetParams,\n QueryStatesOptions,\n} from '../search-params/define.js';\nimport { parseTotal } from '../search-params/parse-total.js';\n\n// ─── Codec Bridge ─────────────────────────────────────────────────\n\n// nuqs's parser contract conflates values timber codecs distinguish:\n// parse() returning null means \"unparseable, substitute defaultValue\",\n// and undefined entries are skipped entirely. Timber codecs can\n// legitimately produce both — bare z.string() yields undefined for absent\n// params (implicit optionality), and a codec may map a present value to\n// null. Wrap those two values in sentinels across the nuqs boundary and\n// unwrap them before handing values back to the caller, so the client\n// hook returns exactly what server-side parse() returns.\n// Unique object references compared by identity — a codec can never\n// produce these from URL input, so user-controlled strings cannot collide\n// with them (unlike string sentinels), and unlike Symbols they survive\n// nuqs's internal string coercion without throwing.\nconst NULL_SENTINEL: object = { timberSentinel: 'null' };\nconst UNDEFINED_SENTINEL: object = { timberSentinel: 'undefined' };\n\nfunction wrapNuqsValue(value: unknown): unknown {\n if (value === null) return NULL_SENTINEL;\n if (value === undefined) return UNDEFINED_SENTINEL;\n return value;\n}\n\nfunction unwrapNuqsValue(value: unknown): unknown {\n if (value === NULL_SENTINEL) return null;\n if (value === UNDEFINED_SENTINEL) return undefined;\n return value;\n}\n\n/**\n * Bridge a timber SearchParamCodec to a nuqs-compatible MultiParser.\n *\n * nuqs parsers: { parse(string) → T|null, serialize?(T) → string, eq?, defaultValue? }\n * timber codecs: { parse(string|string[]|undefined) → T, serialize(T) → string|null }\n *\n * The defaultValue is computed eagerly, through `parseTotal` — the same\n * entry point server-side `parse()` uses, so the hook and the server agree\n * on what an absent param means (a bare nuqs parser answers `null`, not\n * `undefined`; TIM-1350). Codecs are documented to return a default rather\n * than throw, but a throwing codec must not crash every component that\n * mounts the hook — treat its default as undefined and let its error\n * surface from server-side parse() instead.\n *\n * A `null` absent-value is NOT registered as the nuqs default. nuqs\n * already represents an absent key as `null`, so the hook reads the same\n * value either way — but registering it makes `clearOnDefault` fire on\n * `setParams({ q: null })` and delete the key before the bridged\n * `serialize` runs. For a codec that encodes `null` as a real query value\n * (`serialize(null) === 'none'`), that silently disagrees with\n * `buildSearchParams({ q: null })`, which writes it. Same reasoning as\n * `getDefaultSerialized` on the server: a codec with no value for an\n * absent param has no default to register.\n */\nfunction bridgeCodec<T>(codec: SearchParamCodec<T>): MultiParser<T> & { defaultValue: T } {\n let absent: unknown;\n try {\n absent = parseTotal(codec, undefined);\n } catch {\n absent = undefined;\n }\n\n const parser = {\n // `multi`, so nuqs reads the key with `searchParams.getAll()` and hands\n // us EVERY value. A single parser reads `.get()` — the first value only\n // — which is not the domain a timber codec is defined over. The server\n // parses `?tags=a&tags=b` as `['a','b']`; a single parser made the hook\n // answer `['a']` for the same URL, under a declared `string[]` that\n // admitted no such disagreement (TIM-1352). Scalar codecs are unaffected:\n // they receive the array and take `value[0]`, exactly as they do on the\n // server, so first-value-wins is preserved through the same code path\n // rather than through nuqs's reader.\n //\n // nuqs never calls this with an empty array — `isAbsentFromUrl` treats\n // `[]` as absent and answers `defaultValue` directly — which is what\n // keeps the absent case agreeing with the server's `undefined`.\n //\n // Reading every value is only half of it: the values must arrive in the\n // SAME SHAPE the server would have produced, or the divergence just\n // moves. `normalizeRaw` (search-params/define.ts) collapses a\n // single-valued key to a bare string and keeps an array only for a\n // repeated one, so this mirrors that rule exactly. Handing a codec\n // `['3']` where the server hands it `'3'` breaks every codec whose\n // `parse` is written for the scalar case — which is most hand-written\n // ones, contract or no contract.\n type: 'multi' as const,\n // Through parseTotal, not codec.parse. nuqs's own `.withDefault(d)`\n // overrides ONLY `parseServerSide`, so `parseAsInteger.withDefault(1)`\n // on `?page=abc` returned 1 from the server and null from the hook —\n // a divergence the declared non-nullable `number` did not admit.\n parse: (v: readonly string[]) =>\n wrapNuqsValue(parseTotal(codec, v.length === 1 ? v[0] : [...v])),\n serialize: (v: unknown) => {\n const value = unwrapNuqsValue(v);\n // Delegate null to the codec — some codecs encode null as a real\n // query value. undefined has no encoding; nuqs requires a string.\n //\n // ONE element, always. A multi parser may return several — nuqs\n // appends one key per entry — but `Codec.serialize` returns a single\n // string by construction, so timber cannot emit repeated keys here\n // any more than `buildSearchParams` can. Widening that protocol is\n // TIM-1353. Wrapping the same string keeps the URL the hook writes\n // byte-identical to the one `buildSearchParams` writes.\n return [value === undefined ? '' : (codec.serialize(value as T) ?? '')];\n },\n eq: (a: unknown, b: unknown) => {\n if (a === b) return true;\n try {\n return (\n codec.serialize(unwrapNuqsValue(a) as T) === codec.serialize(unwrapNuqsValue(b) as T)\n );\n } catch {\n return false;\n }\n },\n } as MultiParser<T> & { defaultValue: T };\n\n if (absent !== null) parser.defaultValue = wrapNuqsValue(absent) as T;\n return parser;\n}\n\n/**\n * Collect `withUrlKey` aliases off a codec map.\n *\n * `withUrlKey(codec, 'q')` returns a codec carrying `urlKey: 'q'`, so the map\n * alone is enough to reconstruct the aliases — `defineSearchParams` builds its\n * own `urlKeys` from exactly this property.\n */\nfunction deriveUrlKeys(codecs: Record<string, SearchParamCodec<unknown>>): Record<string, string> {\n const result: Record<string, string> = {};\n for (const key of Object.keys(codecs)) {\n const alias = codecs[key]?.urlKey;\n if (alias) result[key] = alias;\n }\n return result;\n}\n\n/**\n * Bridge an entire codec map to nuqs-compatible parsers.\n */\nfunction bridgeCodecs<T extends Record<string, unknown>>(codecs: {\n [K in keyof T]: SearchParamCodec<T[K]>;\n}) {\n const result: Record<string, MultiParser<unknown> & { defaultValue: unknown }> = {};\n for (const key of Object.keys(codecs)) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n result[key] = bridgeCodec(codecs[key as keyof T]) as any;\n }\n return result as { [K in keyof T]: MultiParser<T[K]> & { defaultValue: T[K] } };\n}\n\n// ─── Hook ─────────────────────────────────────────────────────────\n\n/**\n * Read and write typed search params from/to the URL.\n *\n * Delegates to nuqs internally. The timber nuqs adapter (auto-injected in\n * browser-entry.ts) handles RSC navigation on non-shallow updates.\n *\n * Usage:\n * ```ts\n * // Via a SearchParamsDefinition imported from the route's params.ts\n * const [params, setParams] = definition.useQueryStates()\n *\n * // Standalone with inline codecs\n * const [params, setParams] = useQueryStates({\n * page: fromSchema(z.coerce.number().int().min(1).default(1)),\n * })\n * ```\n *\n * There is deliberately no route-string form (`useQueryStates('/products')`).\n * Importing the definition from `params.ts` is the documented way to reach\n * another route's codecs — it needs no runtime registry lookup and so has no\n * \"not registered yet\" failure mode. See design/23-search-params.md\n * §\"Client Access\".\n */\nexport function useQueryStates<T extends Record<string, unknown>>(\n codecs: { [K in keyof T]: SearchParamCodec<T[K]> },\n _options?: QueryStatesOptions,\n urlKeys?: Readonly<Record<string, string>>\n): [T, SetParams<T>] {\n const bridged = bridgeCodecs(codecs);\n\n // Forward hook-level options (shallow, scroll, history) to nuqs.\n // These become the default for all setter calls from this hook instance.\n // Per-call options in setParams(values, opts) override these defaults.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const nuqsOptions: any = {};\n if (_options?.shallow !== undefined) nuqsOptions.shallow = _options.shallow;\n if (_options?.scroll !== undefined) nuqsOptions.scroll = _options.scroll;\n if (_options?.history !== undefined) nuqsOptions.history = _options.history;\n // `withUrlKey` attaches the alias to the codec itself — that is the design's\n // \"URL keys travel with codecs\" principle — so the aliases are derivable\n // here and must be, for the inline codec-map form: nobody passes `urlKeys`\n // on that path, and without this an aliased bundle silently read and wrote\n // the property name instead of the alias. `bindUseQueryStates` still passes\n // the definition's precomputed map, which wins on conflict; it is built from\n // these same codecs, so the two agree by construction rather than by luck.\n const resolvedUrlKeys = { ...deriveUrlKeys(codecs), ...urlKeys };\n if (Object.keys(resolvedUrlKeys).length > 0) {\n nuqsOptions.urlKeys = resolvedUrlKeys;\n }\n\n let values: Record<string, unknown>;\n let setValues: Function;\n try {\n [values, setValues] = nuqsUseQueryStates(bridged, nuqsOptions);\n } catch (err) {\n if (\n err instanceof Error &&\n /Invalid hook call|cannot be called|Cannot read properties of null/i.test(err.message)\n ) {\n throw new Error(\n 'useQueryStates is a client component hook and cannot be called outside a React component. ' +\n 'Use definition.parse(searchParams) in server components instead.'\n );\n }\n throw err;\n }\n\n // Unwrap the null/undefined sentinels the bridge injected (see Codec\n // Bridge above) so callers see exactly what server-side parse() returns.\n // Copy-on-write preserves the identity of nuqs's memoized values object\n // when nothing needs unwrapping.\n let normalized = values;\n for (const key of Object.keys(bridged)) {\n const value = normalized[key];\n if (value === NULL_SENTINEL || value === UNDEFINED_SENTINEL) {\n if (normalized === values) normalized = { ...values };\n normalized[key] = unwrapNuqsValue(value);\n }\n }\n\n // Wrap the nuqs setter to match timber's SetParams<T> signature.\n // nuqs's setter accepts Partial<Nullable<Values>> | UpdaterFn | null.\n // timber's setter accepts Partial<T> with optional SetParamsOptions.\n const setParams: SetParams<T> = (partial, setOptions?) => {\n const nuqsSetOptions: Record<string, unknown> = {};\n if (setOptions?.shallow !== undefined) nuqsSetOptions.shallow = setOptions.shallow;\n if (setOptions?.scroll !== undefined) nuqsSetOptions.scroll = setOptions.scroll;\n if (setOptions?.history !== undefined) nuqsSetOptions.history = setOptions.history;\n // nuqs's update loop skips undefined entries and treats null as a\n // key deletion before serialize runs. Timber semantics:\n // - setParams({ q: undefined }) must clear ?q= (absent = undefined),\n // so explicit undefined maps to a null deletion.\n // - setParams({ q: null }) clears the key only when the codec encodes\n // null as \"omit\" (serialize(null) === null). If the codec encodes\n // null as a real query value, forward the sentinel so the bridged\n // serialize writes it — matching definition.serialize({ q: null }).\n let forwarded: Record<string, unknown> = partial;\n for (const key of Object.keys(partial)) {\n const value = partial[key as keyof T];\n if (value === undefined) {\n if (forwarded === partial) forwarded = { ...partial };\n forwarded[key] = null;\n } else if (value === null) {\n let encoded: string | null = null;\n try {\n encoded = codecs[key as keyof T]?.serialize(null as T[keyof T]) ?? null;\n } catch {\n // Codec can't serialize null — treat as a deletion.\n }\n if (encoded !== null) {\n if (forwarded === partial) forwarded = { ...partial };\n forwarded[key] = NULL_SENTINEL;\n }\n }\n }\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n void setValues(forwarded as any, nuqsSetOptions);\n };\n\n return [normalized as T, setParams];\n}\n\n// ─── Definition binding ───────────────────────────────────────────\n\n/**\n * Create a useQueryStates binding for a SearchParamsDefinition.\n * This is used internally by SearchParamsDefinition.useQueryStates().\n */\nexport function bindUseQueryStates<T extends Record<string, unknown>>(\n definition: SearchParamsDefinition<T>\n): (options?: QueryStatesOptions) => [T, SetParams<T>] {\n return (options?: QueryStatesOptions) => {\n return useQueryStates<T>(definition.codecs, options, definition.urlKeys);\n };\n}\n"],"mappings":"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"}