@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.
- package/dist/_chunks/{resolve-schema-5ma5pp1b.js → resolve-schema-CBR6Lm4i.js} +2 -2
- package/dist/_chunks/{resolve-schema-5ma5pp1b.js.map → resolve-schema-CBR6Lm4i.js.map} +1 -1
- package/dist/_chunks/{schema-bridge-Cc2Gngu1.js → schema-bridge-C83xa9lT.js} +2 -2
- package/dist/_chunks/{schema-bridge-Cc2Gngu1.js.map → schema-bridge-C83xa9lT.js.map} +1 -1
- package/dist/_chunks/{use-query-states-BbU5Ge1V.js → use-query-states-I3JMng6J.js} +29 -5
- package/dist/_chunks/use-query-states-I3JMng6J.js.map +1 -0
- package/dist/client/index.js +1 -1
- package/dist/client/internal.js +1 -1
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/cookies/index.js +1 -1
- package/dist/params/index.js +1 -1
- package/dist/schema-bridge.d.ts +4 -1
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts +30 -4
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.d.ts +1 -1
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +20 -5
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/parse-total.d.ts +14 -3
- package/dist/search-params/parse-total.d.ts.map +1 -1
- package/dist/search-params/serialize-equal.d.ts +9 -0
- package/dist/search-params/serialize-equal.d.ts.map +1 -0
- package/dist/server/internal.js +1 -1
- package/dist/server/internal.js.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +12 -0
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/docs/api/33-api-search-params.mdx +3 -3
- package/docs/learn/05-typed-params.mdx +1 -1
- package/package.json +1 -1
- package/src/client/use-query-states.ts +23 -14
- package/src/schema-bridge.ts +8 -3
- package/src/search-params/define.ts +73 -14
- package/src/search-params/index.ts +1 -0
- package/src/search-params/parse-total.ts +17 -4
- package/src/search-params/serialize-equal.ts +14 -0
- package/src/search-params/wrappers.ts +1 -1
- package/src/server/route-element-builder.ts +11 -1
- package/src/server/slot-resolver.ts +82 -0
- 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,
|
|
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
|
-
|
|
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([])
|
|
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.
|
|
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
|
-
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
}
|
package/src/schema-bridge.ts
CHANGED
|
@@ -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>):
|
|
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.
|
|
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,
|
|
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
|
-
/**
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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,
|
|
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]> };
|
|
@@ -43,12 +43,23 @@
|
|
|
43
43
|
* Design doc: design/23-search-params.md §"nuqs parsers, made total"
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
-
|
|
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
|
|
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>(
|
|
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:
|
|
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 {
|
|
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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"}
|