@timber-js/app 0.2.0-alpha.186 → 0.2.0-alpha.187
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/{actions-C-Rw9vPc.js → actions-35jnMdeJ.js} +4 -3
- package/dist/_chunks/{actions-C-Rw9vPc.js.map → actions-35jnMdeJ.js.map} +1 -1
- package/dist/_chunks/als-registry-C6kcfprT.js +41 -0
- package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -0
- package/dist/_chunks/{als-slots-mFweg276.js → als-slots-BEEIPKYm.js} +3 -4
- package/dist/_chunks/{als-slots-mFweg276.js.map → als-slots-BEEIPKYm.js.map} +1 -1
- package/dist/_chunks/{cache-api-Cd0VZ_Pd.js → cache-api-DjNrIWRR.js} +7 -13
- package/dist/_chunks/cache-api-DjNrIWRR.js.map +1 -0
- package/dist/_chunks/cli-check-CpmN7Nh-.js +256 -0
- package/dist/_chunks/cli-check-CpmN7Nh-.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-CGMp_Psg.js +298 -0
- package/dist/_chunks/cli-schema-sync-CGMp_Psg.js.map +1 -0
- package/dist/_chunks/{cloudflare-Cs0uZXea.js → cloudflare-CGP6BZKO.js} +4 -3
- package/dist/_chunks/{cloudflare-Cs0uZXea.js.map → cloudflare-CGP6BZKO.js.map} +1 -1
- package/dist/_chunks/convention-lint-kXsgc_-7.js +784 -0
- package/dist/_chunks/convention-lint-kXsgc_-7.js.map +1 -0
- package/dist/_chunks/{error-boundary-DpYRI_I1.js → error-boundary-D-ODYX41.js} +25 -2
- package/dist/_chunks/error-boundary-D-ODYX41.js.map +1 -0
- package/dist/_chunks/file-cache-DmX7OqZP.js +454 -0
- package/dist/_chunks/file-cache-DmX7OqZP.js.map +1 -0
- package/dist/_chunks/{logger-N7e5auP0.js → logger-D8xJZXIN.js} +27 -40
- package/dist/_chunks/logger-D8xJZXIN.js.map +1 -0
- package/dist/_chunks/mdx-file-C005ay-P.js +25 -0
- package/dist/_chunks/mdx-file-C005ay-P.js.map +1 -0
- package/dist/_chunks/{plugin-context-DEGLSJs3.js → plugin-context-rCinWLiE.js} +7 -2
- package/dist/_chunks/{plugin-context-DEGLSJs3.js.map → plugin-context-rCinWLiE.js.map} +1 -1
- package/dist/_chunks/purge-store-Byr8XOjU.js +14 -0
- package/dist/_chunks/purge-store-Byr8XOjU.js.map +1 -0
- package/dist/_chunks/{resolve-schema-Dz3fcFUo.js → resolve-schema-5ma5pp1b.js} +2 -2
- package/dist/_chunks/{resolve-schema-Dz3fcFUo.js.map → resolve-schema-5ma5pp1b.js.map} +1 -1
- package/dist/_chunks/rsc-media-type-DRqE_lD_.js +46 -0
- package/dist/_chunks/rsc-media-type-DRqE_lD_.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-CKgHC2MB.js → scanner-C8b0Gcw3.js} +5 -298
- package/dist/_chunks/scanner-C8b0Gcw3.js.map +1 -0
- package/dist/_chunks/schema-bridge-Cc2Gngu1.js +199 -0
- package/dist/_chunks/schema-bridge-Cc2Gngu1.js.map +1 -0
- package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -1
- package/dist/_chunks/{param-value-C8TNYchQ.js → segment-context-CjOlyB8Y.js} +33 -2
- package/dist/_chunks/segment-context-CjOlyB8Y.js.map +1 -0
- package/dist/_chunks/{use-query-states-DFvWd-EA.js → use-query-states-BbU5Ge1V.js} +74 -26
- package/dist/_chunks/use-query-states-BbU5Ge1V.js.map +1 -0
- package/dist/_chunks/{navigation-root-B29qg0_T.js → use-segment-params-C4r4BD9T.js} +129 -5
- package/dist/_chunks/use-segment-params-C4r4BD9T.js.map +1 -0
- package/dist/_chunks/walkers-RzN6AFjr.js +141 -0
- package/dist/_chunks/walkers-RzN6AFjr.js.map +1 -0
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/compress-module.d.ts +12 -0
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.js +54 -2
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cdn/cloudflare-purge.js +30 -0
- package/dist/cdn/cloudflare-purge.js.map +1 -0
- package/dist/cdn/fastly-purge.js +33 -0
- package/dist/cdn/fastly-purge.js.map +1 -0
- package/dist/cdn/index.js +94 -0
- package/dist/cdn/index.js.map +1 -0
- package/dist/cdn/workers-cache-purge.js +35 -0
- package/dist/cdn/workers-cache-purge.js.map +1 -0
- package/dist/cli-check.d.ts +153 -0
- package/dist/cli-check.d.ts.map +1 -0
- package/dist/cli.d.ts +34 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +46 -21
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/index.d.ts +1 -1
- package/dist/client/browser-entry/index.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts +6 -0
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +27 -33
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +21 -14
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts +1 -7
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-commit.d.ts +16 -0
- package/dist/client/navigation-commit.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +9 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +8 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts +9 -3
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +2 -2
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +142 -477
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/plugin-context.d.ts +15 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts +0 -9
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +24 -0
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen-shared.d.ts +3 -44
- package/dist/routing/codegen-shared.d.ts.map +1 -1
- package/dist/routing/codegen-types.d.ts +10 -31
- package/dist/routing/codegen-types.d.ts.map +1 -1
- package/dist/routing/codegen-write.d.ts +51 -0
- package/dist/routing/codegen-write.d.ts.map +1 -0
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts +18 -4
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/export-detect.d.ts +16 -0
- package/dist/routing/export-detect.d.ts.map +1 -1
- package/dist/routing/index.js +3 -2
- package/dist/routing/link-codegen.d.ts +19 -4
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/routing/manifest-codegen.d.ts +1 -7
- package/dist/routing/manifest-codegen.d.ts.map +1 -1
- package/dist/routing/types.d.ts +0 -6
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts +60 -9
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts +62 -8
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.d.ts +0 -1
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +66 -29
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/parse-total.d.ts +70 -0
- package/dist/search-params/parse-total.d.ts.map +1 -0
- package/dist/search-params/wrappers.d.ts +26 -3
- package/dist/search-params/wrappers.d.ts.map +1 -1
- package/dist/server/access-gate.d.ts +19 -8
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +16 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/compress.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +148 -15
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +2 -2
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +85 -15
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/index.d.ts +0 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.d.ts +3 -1
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +343 -230
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-collector.d.ts +52 -0
- package/dist/server/metadata-collector.d.ts.map +1 -0
- package/dist/server/param-coercion.d.ts +12 -5
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-helpers.d.ts.map +1 -1
- package/dist/server/pipeline-outcome.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +23 -0
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +1 -12
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
- package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +0 -7
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +0 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +1 -3
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +12 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +0 -2
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +11 -0
- package/dist/server/ssr-bridge-types.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts +0 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/static-generator.d.ts.map +1 -1
- package/dist/server/status-code-resolver.d.ts +8 -1
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +28 -36
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/server/types.d.ts +12 -7
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/utils/element-type.d.ts +40 -0
- package/dist/server/utils/element-type.d.ts.map +1 -0
- package/dist/shared/rsc-media-type.d.ts +40 -0
- package/dist/shared/rsc-media-type.d.ts.map +1 -0
- package/docs/api/30-api-server.mdx +1 -1
- package/docs/api/33-api-search-params.mdx +38 -16
- package/docs/api/35-api-typescript.mdx +3 -3
- package/docs/api/36-cli.mdx +34 -7
- package/docs/learn/00-introduction.mdx +1 -1
- package/docs/learn/02-pages-and-layouts.mdx +1 -1
- package/docs/learn/05-typed-params.mdx +8 -8
- package/docs/learn/07-typed-routes.mdx +12 -7
- package/docs/learn/11-error-handling.mdx +16 -0
- package/docs/more/01-advanced-routing.mdx +1 -1
- package/docs/more/03-coming-from-nextjs.mdx +2 -2
- package/docs/more/50-ai-agent-instructions.mdx +6 -4
- package/package.json +8 -5
- package/src/adapters/cloudflare.ts +4 -1
- package/src/adapters/compress-module.ts +79 -1
- package/src/cli-check.ts +458 -0
- package/src/cli.ts +59 -24
- package/src/client/browser-entry/action-dispatch.ts +2 -1
- package/src/client/browser-entry/index.ts +0 -5
- package/src/client/error-boundary.tsx +65 -1
- package/src/client/index.ts +14 -3
- package/src/client/link.tsx +65 -64
- package/src/client/navigation-commit.ts +27 -4
- package/src/client/params-context.ts +4 -4
- package/src/client/router-pipeline.ts +4 -0
- package/src/client/router.ts +1 -0
- package/src/client/rsc-fetch.ts +14 -4
- package/src/client/segment-cache.ts +8 -0
- package/src/client/use-query-states.ts +102 -39
- package/src/cookies/define-cookie.ts +6 -1
- package/src/index.ts +20 -3
- package/src/plugin-context.ts +26 -0
- package/src/plugins/routing.ts +84 -146
- package/src/plugins/shims.ts +0 -1
- package/src/plugins/static-build.ts +78 -24
- package/src/routing/codegen-shared.ts +3 -79
- package/src/routing/codegen-types.ts +10 -31
- package/src/routing/codegen-write.ts +139 -0
- package/src/routing/codegen.ts +56 -182
- package/src/routing/convention-lint.ts +139 -40
- package/src/routing/export-detect.ts +151 -7
- package/src/routing/link-codegen.ts +32 -65
- package/src/routing/manifest-codegen.ts +1 -59
- package/src/routing/scanner.ts +3 -3
- package/src/routing/types.ts +0 -6
- package/src/schema-bridge.ts +180 -58
- package/src/search-params/define.ts +102 -37
- package/src/search-params/index.ts +0 -1
- package/src/search-params/parse-total.ts +78 -0
- package/src/search-params/wrappers.ts +60 -11
- package/src/server/access-gate.tsx +60 -40
- package/src/server/action-handler.ts +1 -4
- package/src/server/als-registry.ts +16 -0
- package/src/server/compress.ts +9 -1
- package/src/server/deny-boundary.ts +269 -41
- package/src/server/deny-renderer.ts +32 -21
- package/src/server/error-boundary-wrapper.ts +166 -79
- package/src/server/index.ts +1 -3
- package/src/server/internal.ts +2 -2
- package/src/server/metadata-collector.ts +115 -0
- package/src/server/param-coercion.ts +13 -61
- package/src/server/pipeline-helpers.ts +2 -2
- package/src/server/pipeline-outcome.ts +2 -1
- package/src/server/pipeline-phases.ts +9 -9
- package/src/server/primitives.ts +25 -0
- package/src/server/route-element-builder.ts +167 -170
- package/src/server/rsc-cache-key-guard.ts +2 -8
- package/src/server/rsc-entry/deny-fallback.ts +3 -2
- package/src/server/rsc-entry/error-renderer.ts +31 -11
- package/src/server/rsc-entry/helpers.ts +2 -12
- package/src/server/rsc-entry/index.ts +0 -5
- package/src/server/rsc-entry/render-route.ts +14 -11
- package/src/server/rsc-entry/revalidate-renderer.ts +2 -1
- package/src/server/rsc-entry/rsc-payload.ts +104 -20
- package/src/server/rsc-entry/rsc-stream.ts +25 -2
- package/src/server/rsc-entry/ssr-renderer.ts +28 -16
- package/src/server/slot-resolver.ts +10 -2
- package/src/server/ssr-bridge-types.ts +12 -0
- package/src/server/ssr-entry.ts +8 -6
- package/src/server/static-generator.ts +3 -2
- package/src/server/status-code-resolver.ts +28 -11
- package/src/server/tree-builder.ts +35 -218
- package/src/server/types.ts +12 -7
- package/src/server/utils/element-type.ts +72 -0
- package/src/shared/rsc-media-type.ts +43 -0
- package/dist/_chunks/cache-api-Cd0VZ_Pd.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-CKgHC2MB.js.map +0 -1
- package/dist/_chunks/error-boundary-DpYRI_I1.js.map +0 -1
- package/dist/_chunks/logger-N7e5auP0.js.map +0 -1
- package/dist/_chunks/navigation-root-B29qg0_T.js.map +0 -1
- package/dist/_chunks/param-value-C8TNYchQ.js.map +0 -1
- package/dist/_chunks/registry-DbJPKoBp.js +0 -20
- package/dist/_chunks/registry-DbJPKoBp.js.map +0 -1
- package/dist/_chunks/schema-bridge-DT_Tn0Xf.js +0 -119
- package/dist/_chunks/schema-bridge-DT_Tn0Xf.js.map +0 -1
- package/dist/_chunks/segment-context-ZDnXDkbz.js +0 -34
- package/dist/_chunks/segment-context-ZDnXDkbz.js.map +0 -1
- package/dist/_chunks/use-query-states-DFvWd-EA.js.map +0 -1
- package/dist/_chunks/use-segment-params-ClyUNq4d.js +0 -128
- package/dist/_chunks/use-segment-params-ClyUNq4d.js.map +0 -1
- package/dist/_chunks/walkers-BhhwI9TD.js +0 -936
- package/dist/_chunks/walkers-BhhwI9TD.js.map +0 -1
- package/dist/search-params/registry.d.ts +0 -20
- package/dist/search-params/registry.d.ts.map +0 -1
- package/dist/segment-params/define.d.ts +0 -83
- package/dist/segment-params/define.d.ts.map +0 -1
- package/dist/segment-params/index.d.ts +0 -3
- package/dist/segment-params/index.d.ts.map +0 -1
- package/dist/segment-params/index.js +0 -70
- package/dist/segment-params/index.js.map +0 -1
- package/src/search-params/registry.ts +0 -31
- package/src/segment-params/define.ts +0 -226
- package/src/segment-params/index.ts +0 -9
package/src/schema-bridge.ts
CHANGED
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
* This module is the single source of truth for:
|
|
6
6
|
* - StandardSchemaV1 interface (subset of the Standard Schema spec)
|
|
7
7
|
* - validateSync() helper
|
|
8
|
-
* - fromSchema() — bridge
|
|
9
|
-
* - fromArraySchema() —
|
|
8
|
+
* - fromSchema() — search-param bridge; scalar shape first, array second
|
|
9
|
+
* - fromArraySchema() — same loop, array shape first
|
|
10
|
+
* - fromCookieSchema() — scalar shape only; a cookie has no array shape
|
|
11
|
+
* - fromParamSchema() — route params; throws on failure (invalid param → 404)
|
|
12
|
+
*
|
|
13
|
+
* One parse loop, four candidate orders. Which shapes a bridge offers, and
|
|
14
|
+
* in what order, is the ONLY thing that differs — see §"Shape tolerance"
|
|
15
|
+
* below and in design/23-search-params.md. `serialize` is deliberately NOT
|
|
16
|
+
* shared: `null` means "omit the key" for a search param and "delete the
|
|
17
|
+
* cookie" for a cookie, so unifying it silently changed what
|
|
18
|
+
* `cookie.set([])` did (TIM-1352).
|
|
10
19
|
*
|
|
11
20
|
* These are re-exported from @timber-js/app/search-params, @timber-js/app/segment-params,
|
|
12
21
|
* and @timber-js/app/cookies for convenience. The canonical import is
|
|
@@ -122,18 +131,21 @@ export function fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T
|
|
|
122
131
|
* @param fieldName - used in error messages
|
|
123
132
|
* @param value - the codec or schema to resolve
|
|
124
133
|
* @param mode - 'param' uses fromParamSchema (throws on parse failure),
|
|
125
|
-
* 'search' uses fromSchema (falls back to default on failure
|
|
134
|
+
* 'search' uses fromSchema (falls back to default on failure,
|
|
135
|
+
* tolerant of the repeated-key array shape),
|
|
136
|
+
* 'cookie' uses fromCookieSchema (same, minus the array shape,
|
|
137
|
+
* which a cookie value cannot have)
|
|
126
138
|
*/
|
|
127
139
|
export function resolveCodecOrSchema(
|
|
128
140
|
fieldName: string,
|
|
129
141
|
value: unknown,
|
|
130
|
-
mode: 'param' | 'search' = 'search'
|
|
142
|
+
mode: 'param' | 'search' | 'cookie' = 'search'
|
|
131
143
|
): Codec<unknown> {
|
|
132
144
|
if (isCodec(value)) return value;
|
|
133
145
|
if (isStandardSchema(value)) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
if (mode === 'param') return fromParamSchema(fieldName, value);
|
|
147
|
+
if (mode === 'cookie') return fromCookieSchema(value) as Codec<unknown>;
|
|
148
|
+
return fromSchema(value) as Codec<unknown>;
|
|
137
149
|
}
|
|
138
150
|
throw new Error(
|
|
139
151
|
`[timber] Field '${fieldName}' is not a valid codec or Standard Schema. ` +
|
|
@@ -146,51 +158,169 @@ export function resolveCodecOrSchema(
|
|
|
146
158
|
// fromSchema — bridge from Standard Schema to Codec<T>
|
|
147
159
|
// ---------------------------------------------------------------------------
|
|
148
160
|
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Shape tolerance — the shared parse loop for both bridges
|
|
163
|
+
//
|
|
164
|
+
// A URL value is `string | string[] | undefined`, but a schema is written
|
|
165
|
+
// against ONE of those: `z.string()` wants `'a'`, `z.array(z.string())`
|
|
166
|
+
// wants `['a']`. Nothing in Standard Schema says which — `~standard.types`
|
|
167
|
+
// exists at the type level only, and probing at runtime is not reliable
|
|
168
|
+
// (design/23-search-params.md §"Shape tolerance"). So a bridge tries BOTH
|
|
169
|
+
// shapes rather than committing to one and discarding the value when it
|
|
170
|
+
// guessed wrong.
|
|
171
|
+
//
|
|
172
|
+
// Each bridge only chooses WHICH candidates, and in what order. The first
|
|
173
|
+
// candidate is always the shape that bridge already passed, so nothing that
|
|
174
|
+
// parses today changes meaning; a later one is reached only where the old
|
|
175
|
+
// code fell through to the default. `fromCookieSchema` offers ONE candidate,
|
|
176
|
+
// because a cookie has no array shape at all.
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The scalar shape, and only it. A value that cannot be repeated — a cookie —
|
|
181
|
+
* has no array shape to tolerate, so its bridge stays exactly where it was.
|
|
182
|
+
*/
|
|
183
|
+
function scalarOnly(value: string | string[] | undefined): unknown[] {
|
|
184
|
+
return [Array.isArray(value) ? value[0] : value];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Candidate inputs for the scalar bridge: scalar first, array second. */
|
|
188
|
+
function scalarFirst(value: string | string[] | undefined): unknown[] {
|
|
189
|
+
if (value === undefined) return [undefined];
|
|
190
|
+
if (typeof value === 'string') return [value, [value]];
|
|
191
|
+
// Repeated keys: `value[0]` matches URLSearchParams.get().
|
|
192
|
+
//
|
|
193
|
+
// A raw `[]` carries no value at all. `value[0]` is `undefined`, which is
|
|
194
|
+
// the shape the scalar bridge has always passed for it, so `undefined`
|
|
195
|
+
// stays FIRST — an empty array must keep meaning "absent" and land on the
|
|
196
|
+
// schema's default. Only `defineSearchParams().parse({ tags: [] })`, the
|
|
197
|
+
// record form, can produce one: URLSearchParams never yields an empty list.
|
|
198
|
+
return value.length > 0 ? [value[0], value] : [undefined, value];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Candidate inputs for the array bridge: array first, scalar second. */
|
|
202
|
+
function arrayFirst(value: string | string[] | undefined): unknown[] {
|
|
203
|
+
if (value === undefined) return [undefined];
|
|
204
|
+
if (typeof value === 'string') return [[value], value];
|
|
205
|
+
// `[]` first here for the same reason, inverted: the array bridge has
|
|
206
|
+
// always passed the empty array straight through.
|
|
207
|
+
return value.length > 0 ? [value, value[0]] : [value, undefined];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Validate `value` against `schema` in each candidate shape, then fall back
|
|
212
|
+
* to the schema's default. Shared by every bridge — they differ only in
|
|
213
|
+
* which candidates they offer, and in what order.
|
|
214
|
+
*/
|
|
215
|
+
function parseThroughSchema<T>(
|
|
216
|
+
schema: StandardSchemaV1<T>,
|
|
217
|
+
candidates: (value: string | string[] | undefined) => unknown[],
|
|
218
|
+
value: string | string[] | undefined
|
|
219
|
+
): T {
|
|
220
|
+
const inputs = candidates(value);
|
|
221
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
222
|
+
// A throw from the FIRST candidate propagates, unchanged: that is the
|
|
223
|
+
// shape the schema was written against, and a throw from it is the
|
|
224
|
+
// deliberate signal design/23 protects — an app schema may `redirect()`
|
|
225
|
+
// or `notFound()` on a value it refuses, and `validateSync` throws on
|
|
226
|
+
// an async schema.
|
|
227
|
+
//
|
|
228
|
+
// A throw from a LATER candidate is ours, not the app's. We invented
|
|
229
|
+
// that shape; the schema never agreed to receive it. A scalar-only
|
|
230
|
+
// hand-written validator doing `value.toUpperCase()`, or a schema that
|
|
231
|
+
// is async for the array shape only, would turn a field that used to
|
|
232
|
+
// fall back to its default into a render-phase 500. Treat it as "this
|
|
233
|
+
// shape does not fit" and keep going.
|
|
234
|
+
let result: StandardSchemaResult<T>;
|
|
235
|
+
try {
|
|
236
|
+
result = validateSync(schema, inputs[i]);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (i === 0) throw error;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (!result.issues) {
|
|
242
|
+
return result.value;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// No shape validated — try parsing undefined to get the default.
|
|
247
|
+
// Re-validate each time so factory defaults (e.g. .default(() => []))
|
|
248
|
+
// produce fresh values.
|
|
249
|
+
const defaultResult = validateSync(schema, undefined);
|
|
250
|
+
if (!defaultResult.issues) {
|
|
251
|
+
return defaultResult.value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// No default available — the field is implicitly optional. Return
|
|
255
|
+
// undefined; defineSearchParams widens the field's inferred type to
|
|
256
|
+
// T | undefined via InferField so this doesn't lie.
|
|
257
|
+
// design/23-search-params.md §"Implicit Optionality"
|
|
258
|
+
return undefined as T;
|
|
259
|
+
}
|
|
260
|
+
|
|
149
261
|
/**
|
|
150
262
|
* Bridge a Standard Schema-compatible schema (Zod, Valibot, ArkType) to a
|
|
151
263
|
* Codec<T>.
|
|
152
264
|
*
|
|
153
|
-
* Parse: coerces the raw
|
|
154
|
-
*
|
|
155
|
-
*
|
|
265
|
+
* Parse: coerces the raw URL value through the schema, trying the scalar
|
|
266
|
+
* shape and then the array shape (see `scalarFirst`), so an array schema
|
|
267
|
+
* works bare: `z.array(z.string()).default([])` yields `[]` when absent,
|
|
268
|
+
* `['a']` for `?tags=a`, and `['a','b']` for `?tags=a&tags=b`. When no
|
|
269
|
+
* shape validates, parses `undefined` to get the schema's default (the
|
|
270
|
+
* schema should have a `.default()` call). If that also fails, returns
|
|
271
|
+
* `undefined`.
|
|
156
272
|
*
|
|
157
|
-
* Serialize:
|
|
273
|
+
* Serialize: `String()` for primitives, `null` for null/undefined. Note
|
|
274
|
+
* that `String(['a','b'])` is `'a,b'` — the same string `fromArraySchema`
|
|
275
|
+
* writes — so a bare array schema round-trips exactly as design/09
|
|
276
|
+
* §"Array params" describes.
|
|
277
|
+
*
|
|
278
|
+
* This is the SEARCH-PARAM bridge. Cookies use `fromCookieSchema`; a
|
|
279
|
+
* cookie has no array shape (see below).
|
|
158
280
|
*
|
|
159
281
|
* ```ts
|
|
160
|
-
* import { fromSchema } from '@timber-js/app/codec'
|
|
161
282
|
* import { z } from 'zod/v4'
|
|
162
283
|
*
|
|
163
284
|
* const pageCodec = fromSchema(z.coerce.number().int().min(1).default(1))
|
|
285
|
+
* const tagsCodec = fromSchema(z.array(z.string()).default([]))
|
|
164
286
|
* ```
|
|
165
287
|
*/
|
|
166
288
|
export function fromSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
|
|
167
289
|
return {
|
|
168
|
-
parse(value: string | string[] | undefined): T
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// Try parsing the raw value
|
|
174
|
-
const result = validateSync(schema, input);
|
|
175
|
-
if (!result.issues) {
|
|
176
|
-
return result.value;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// On failure, try parsing undefined to get the default.
|
|
180
|
-
// Re-validate each time so factory defaults (e.g. .default(() => []))
|
|
181
|
-
// produce fresh values.
|
|
182
|
-
const defaultResult = validateSync(schema, undefined);
|
|
183
|
-
if (!defaultResult.issues) {
|
|
184
|
-
return defaultResult.value;
|
|
290
|
+
parse: (value: string | string[] | undefined): T =>
|
|
291
|
+
parseThroughSchema(schema, scalarFirst, value),
|
|
292
|
+
serialize(value: T): string | null {
|
|
293
|
+
if (value === null || value === undefined) {
|
|
294
|
+
return null;
|
|
185
295
|
}
|
|
186
|
-
|
|
187
|
-
// No default available — the field is implicitly optional. Return
|
|
188
|
-
// undefined; defineSearchParams widens the field's inferred type to
|
|
189
|
-
// T | undefined via InferField so this doesn't lie.
|
|
190
|
-
// design/23-search-params.md §"Implicit Optionality"
|
|
191
|
-
return undefined as T;
|
|
296
|
+
return String(value);
|
|
192
297
|
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
193
300
|
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// fromCookieSchema — bridge for cookie values
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Bridge a Standard Schema for a COOKIE value.
|
|
307
|
+
*
|
|
308
|
+
* Identical to `fromSchema` minus the array shape, because a cookie cannot
|
|
309
|
+
* carry one: `Cookie:` headers and `document.cookie` yield a single string
|
|
310
|
+
* per name, and there is no repeated-key concept to be tolerant of. Sharing
|
|
311
|
+
* the search-param bridge here meant a cookie written by this very codec —
|
|
312
|
+
* `serialize(['a','b'])` → `a,b` — read back as the one-element array
|
|
313
|
+
* `['a,b']` instead of failing to its default: no repeated key in sight,
|
|
314
|
+
* just a delimiter reinterpreted as data.
|
|
315
|
+
*
|
|
316
|
+
* The `serialize` contract also differs by domain and must not be unified:
|
|
317
|
+
* for a search param `null` means "omit the key", for a cookie it means
|
|
318
|
+
* **delete the cookie** (design/29-cookies.md).
|
|
319
|
+
*/
|
|
320
|
+
export function fromCookieSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
|
|
321
|
+
return {
|
|
322
|
+
parse: (value: string | string[] | undefined): T =>
|
|
323
|
+
parseThroughSchema(schema, scalarOnly, value),
|
|
194
324
|
serialize(value: T): string | null {
|
|
195
325
|
if (value === null || value === undefined) {
|
|
196
326
|
return null;
|
|
@@ -214,32 +344,24 @@ export function fromSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
|
|
|
214
344
|
*
|
|
215
345
|
* const tagsCodec = fromArraySchema(z.array(z.string()).default([]))
|
|
216
346
|
* ```
|
|
347
|
+
*
|
|
348
|
+
* Since TIM-1352 a bare array schema works through `fromSchema` too, so
|
|
349
|
+
* this is no longer required to make an array field parse. Two cases still
|
|
350
|
+
* want it, both listed in design/23 §"Shape tolerance":
|
|
351
|
+
*
|
|
352
|
+
* 1. A **permissive** schema meant as an array — one that accepts a string
|
|
353
|
+
* as readily as an array (`z.any()`, a hand-written coercing validator)
|
|
354
|
+
* — where scalar-first ordering would settle on the scalar shape.
|
|
355
|
+
* 2. An array schema wrapped in `.catch([])`, which swallows the failure
|
|
356
|
+
* that would otherwise trigger the array attempt.
|
|
357
|
+
*
|
|
358
|
+
* It also serializes an empty array to `null` (omitting the key) where
|
|
359
|
+
* `fromSchema` writes `''`.
|
|
217
360
|
*/
|
|
218
361
|
export function fromArraySchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
|
|
219
362
|
return {
|
|
220
|
-
parse(value: string | string[] | undefined): T
|
|
221
|
-
|
|
222
|
-
let input: unknown = value;
|
|
223
|
-
if (typeof value === 'string') {
|
|
224
|
-
input = [value];
|
|
225
|
-
} else if (value === undefined) {
|
|
226
|
-
input = undefined;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const result = validateSync(schema, input);
|
|
230
|
-
if (!result.issues) {
|
|
231
|
-
return result.value;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// On failure, try undefined for default
|
|
235
|
-
const defaultResult = validateSync(schema, undefined);
|
|
236
|
-
if (!defaultResult.issues) {
|
|
237
|
-
return defaultResult.value;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return undefined as T;
|
|
241
|
-
},
|
|
242
|
-
|
|
363
|
+
parse: (value: string | string[] | undefined): T =>
|
|
364
|
+
parseThroughSchema(schema, arrayFirst, value),
|
|
243
365
|
serialize(value: T): string | null {
|
|
244
366
|
if (value === null || value === undefined) {
|
|
245
367
|
return null;
|
|
@@ -20,6 +20,7 @@ import type { Codec } from '../codec.js';
|
|
|
20
20
|
// can register the getter without importing this file, which would drag
|
|
21
21
|
// `nuqs` into every server entry (TIM-1298). See shared/als-slots.ts.
|
|
22
22
|
import { getSearchParamsFromAls } from '../shared/als-slots.js';
|
|
23
|
+
import { parseTotal } from './parse-total.js';
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// Types
|
|
@@ -28,13 +29,43 @@ import { getSearchParamsFromAls } from '../shared/als-slots.js';
|
|
|
28
29
|
/**
|
|
29
30
|
* A codec that converts between URL string values and typed values.
|
|
30
31
|
*
|
|
31
|
-
*
|
|
32
|
+
* `parse` receives the RAW url value: `string | string[] | undefined`.
|
|
33
|
+
* A codec must be TOTAL over that domain — return a default rather than
|
|
34
|
+
* throwing on an absent or repeated param.
|
|
35
|
+
*
|
|
36
|
+
* A codec whose own `parse` covers only a PRESENT, SINGLE-VALUED param
|
|
37
|
+
* may publish `parseServerSide` instead, and timber calls that. nuqs
|
|
38
|
+
* parsers do exactly this, which is how they become total here: absent →
|
|
39
|
+
* `null` (or their `withDefault` value), repeated → the first value, and
|
|
40
|
+
* a throw from the inner parse → `null`. See
|
|
41
|
+
* design/23-search-params.md §'nuqs parsers, made total',
|
|
42
|
+
* `parse-total.ts`, and tests/nuqs-codec-boundary.test.ts (TIM-1350).
|
|
43
|
+
*
|
|
32
44
|
* Standard Schema objects (Zod, Valibot, ArkType) are auto-detected
|
|
33
|
-
* by defineSearchParams and wrapped via fromSchema
|
|
45
|
+
* by defineSearchParams and wrapped via fromSchema; those ARE total
|
|
46
|
+
* through `parse` and are called that way.
|
|
34
47
|
*/
|
|
35
48
|
export interface SearchParamCodec<T> extends Codec<T> {
|
|
36
49
|
/** Optional URL key alias, set by withUrlKey(). */
|
|
37
50
|
urlKey?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Optional TOTAL entry point over `string | string[] | undefined`,
|
|
53
|
+
* preferred over `parse` wherever timber invokes a codec. Declared here
|
|
54
|
+
* so the protocol is typed rather than duck-checked at each wrapper:
|
|
55
|
+
* anything that reconstructs a codec has to carry it, and a wrapper
|
|
56
|
+
* cannot carry a property the interface does not admit.
|
|
57
|
+
*
|
|
58
|
+
* It returns `T`, not `T | null`. This is the entry point timber calls,
|
|
59
|
+
* so whatever it returns IS the field's type — admitting a `null` the
|
|
60
|
+
* field type did not carry would let `SearchParamCodec<string>` produce
|
|
61
|
+
* `null` for an absent param under a non-nullable annotation. A codec
|
|
62
|
+
* whose absent-case answer is `null` declares that in `T`, exactly as a
|
|
63
|
+
* bare nuqs parser does: `parseAsString` is a `SearchParamCodec<string |
|
|
64
|
+
* null>` here, never a `SearchParamCodec<string>`.
|
|
65
|
+
*
|
|
66
|
+
* nuqs parser builders satisfy this. See parse-total.ts.
|
|
67
|
+
*/
|
|
68
|
+
parseServerSide?(value: string | string[] | undefined): T;
|
|
38
69
|
}
|
|
39
70
|
|
|
40
71
|
/** A codec with a URL key alias attached via withUrlKey(). */
|
|
@@ -93,7 +124,7 @@ export interface SearchParamsDefinition<T extends Record<string, unknown>> {
|
|
|
93
124
|
*
|
|
94
125
|
* ```tsx
|
|
95
126
|
* // app/products/page.tsx
|
|
96
|
-
* import { searchParams } from './params'
|
|
127
|
+
* import { searchParams } from './search-params'
|
|
97
128
|
* export default function Page() {
|
|
98
129
|
* const { page, category } = searchParams.get()
|
|
99
130
|
* }
|
|
@@ -112,15 +143,21 @@ export interface SearchParamsDefinition<T extends Record<string, unknown>> {
|
|
|
112
143
|
/** Pick a subset of keys. Preserves codecs and aliases. */
|
|
113
144
|
pick<K extends keyof T & string>(...keys: K[]): SearchParamsDefinition<Pick<T, K>>;
|
|
114
145
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Serialize values to a query string (no leading '?'), omitting defaults
|
|
148
|
+
* and applying `withUrlKey` aliases. This is the value to pass to
|
|
149
|
+
* `<Link searchParams={...}>`.
|
|
150
|
+
*
|
|
151
|
+
* Returns a **string**, not a `URLSearchParams`. A `<Link>` prop crosses
|
|
152
|
+
* the RSC Flight boundary, and `URLSearchParams` is iterable — React
|
|
153
|
+
* serializes it as an entries array, which arrives as `[['pg','2'], …]`
|
|
154
|
+
* and renders as `?0=pg&0=2`. A string survives intact.
|
|
155
|
+
*/
|
|
156
|
+
buildSearchParams(values: Partial<T>): string;
|
|
117
157
|
|
|
118
158
|
/** Build a full path with query string, omitting defaults. */
|
|
119
159
|
href(pathname: string, values: Partial<T>): string;
|
|
120
160
|
|
|
121
|
-
/** Build a URLSearchParams instance, omitting defaults. */
|
|
122
|
-
toSearchParams(values: Partial<T>): URLSearchParams;
|
|
123
|
-
|
|
124
161
|
/** Read-only codec map for spreading into .extend(). */
|
|
125
162
|
codecs: { [K in keyof T]: SearchParamCodec<T[K]> };
|
|
126
163
|
|
|
@@ -159,6 +196,21 @@ type InferSchemaInput<V> = V extends { '~standard': { types?: infer TS } }
|
|
|
159
196
|
/**
|
|
160
197
|
* Infer the output type from either a SearchParamCodec or a StandardSchemaV1.
|
|
161
198
|
*
|
|
199
|
+
* A codec publishing `parseServerSide` is read through THAT signature, not
|
|
200
|
+
* through `parse` — it is the entry point timber actually calls, and it is
|
|
201
|
+
* the one that tells the truth about absent input. A bare `parseAsString`
|
|
202
|
+
* declares `parse(value: string): string` but answers `null` for a missing
|
|
203
|
+
* param, so the field is `string | null`; `parseAsInteger.withDefault(1)`
|
|
204
|
+
* narrows its own `parseServerSide` to `NonNullable<number>` and the field
|
|
205
|
+
* stays `number`. Reading `parse` instead produced a non-nullable type for
|
|
206
|
+
* a nullable field (TIM-1350).
|
|
207
|
+
*
|
|
208
|
+
* The match is structural, not nuqs-specific: any codec declaring that
|
|
209
|
+
* signature opts into being read through it, which is exactly the contract
|
|
210
|
+
* `parseTotal` applies at runtime. The two must stay in step — a type
|
|
211
|
+
* inferred from `parse` while the runtime calls `parseServerSide` is the
|
|
212
|
+
* lie this branch exists to remove.
|
|
213
|
+
*
|
|
162
214
|
* Schemas whose input type rejects `undefined` (e.g. bare `z.string()`, whose
|
|
163
215
|
* input is `string`) are implicitly optional: the URL might not contain the
|
|
164
216
|
* param, and fromSchema returns `undefined` when the schema rejects absent
|
|
@@ -171,8 +223,11 @@ type InferSchemaInput<V> = V extends { '~standard': { types?: infer TS } }
|
|
|
171
223
|
* keeps its narrow output type even though absent input yields `undefined`
|
|
172
224
|
* at runtime. Add `.default()` to coerce schemas for accurate types.
|
|
173
225
|
*/
|
|
174
|
-
export type InferField<V> =
|
|
175
|
-
|
|
226
|
+
export type InferField<V> = V extends {
|
|
227
|
+
parseServerSide(value: string | string[] | undefined): infer R;
|
|
228
|
+
}
|
|
229
|
+
? R
|
|
230
|
+
: V extends SearchParamCodec<infer T>
|
|
176
231
|
? T
|
|
177
232
|
: V extends StandardSchemaV1<infer T>
|
|
178
233
|
? undefined extends InferSchemaInput<V>
|
|
@@ -210,13 +265,36 @@ function normalizeRaw(
|
|
|
210
265
|
* default-omission: when serialize(value) === serialize(parse(undefined)),
|
|
211
266
|
* the field is omitted from the URL.
|
|
212
267
|
*
|
|
268
|
+
* Goes through `parseTotal` for the same reason request-time parsing does:
|
|
269
|
+
* a nuqs parser's own `parse` throws on absent input, and this is the
|
|
270
|
+
* absent case by construction.
|
|
271
|
+
*
|
|
272
|
+
* **A codec with no value for an absent param has no default to omit**,
|
|
273
|
+
* and `null` is returned rather than `serialize(null)`. Serializing it
|
|
274
|
+
* makes the "no value" case collide with a real one: `parseAsInteger`
|
|
275
|
+
* serializes `null` as the string `'null'`, so `buildSearchParams({ q:
|
|
276
|
+
* 'null' })` would silently drop a legitimate value (before TIM-1350 the
|
|
277
|
+
* absent parse was `undefined` and the swallowed input was the string
|
|
278
|
+
* `'undefined'` — same defect, a less likely input). Nothing is lost:
|
|
279
|
+
* `buildSearchParams` already skips a field whose `serialize` returns
|
|
280
|
+
* `null`, so a codec that encodes "no value" as an omission behaves
|
|
281
|
+
* identically, and one that encodes it as a real query value now writes
|
|
282
|
+
* it instead of dropping it.
|
|
283
|
+
*
|
|
213
284
|
* Codecs are documented to return a default rather than throw, but a
|
|
214
285
|
* hand-written codec that throws on absent input must not turn definition
|
|
215
|
-
* into a crash — treat its default as null (nothing to omit).
|
|
286
|
+
* into a crash — treat its default as null (nothing to omit). `serialize`
|
|
287
|
+
* is inside the try for the same reason.
|
|
288
|
+
*
|
|
289
|
+
* Typed `SearchParamCodec<unknown>` rather than generic on purpose: the
|
|
290
|
+
* absent-input value is whatever the codec's total entry point returns,
|
|
291
|
+
* which for a nuqs parser is `T | null` while its `serialize` declares
|
|
292
|
+
* `T`. Widening to `unknown` states that honestly instead of casting.
|
|
216
293
|
*/
|
|
217
|
-
function getDefaultSerialized
|
|
294
|
+
function getDefaultSerialized(codec: SearchParamCodec<unknown>): string | null {
|
|
218
295
|
try {
|
|
219
|
-
|
|
296
|
+
const absent = parseTotal(codec, undefined);
|
|
297
|
+
return absent === null || absent === undefined ? null : codec.serialize(absent);
|
|
220
298
|
} catch {
|
|
221
299
|
return null;
|
|
222
300
|
}
|
|
@@ -382,7 +460,7 @@ function buildDefinition<T extends Record<string, unknown>>(
|
|
|
382
460
|
for (const prop of Object.keys(codecMap)) {
|
|
383
461
|
const urlKey = getUrlKey(prop);
|
|
384
462
|
const rawValue = normalized[urlKey];
|
|
385
|
-
result[prop] = (codecMap[prop as keyof T] as SearchParamCodec<unknown
|
|
463
|
+
result[prop] = parseTotal(codecMap[prop as keyof T] as SearchParamCodec<unknown>, rawValue);
|
|
386
464
|
}
|
|
387
465
|
|
|
388
466
|
return result as T;
|
|
@@ -406,8 +484,14 @@ function buildDefinition<T extends Record<string, unknown>>(
|
|
|
406
484
|
return parseSync(raw);
|
|
407
485
|
}
|
|
408
486
|
|
|
409
|
-
// ----
|
|
410
|
-
|
|
487
|
+
// ---- buildSearchParams ----
|
|
488
|
+
//
|
|
489
|
+
// Returns a query string. It used to have a URLSearchParams-returning
|
|
490
|
+
// sibling (`toSearchParams`) that `<Link>` consumed; that shape cannot
|
|
491
|
+
// cross the RSC Flight boundary (see the interface docstring), and having
|
|
492
|
+
// two methods produce the same query two ways was a drift waiting to
|
|
493
|
+
// happen. One method now.
|
|
494
|
+
function buildSearchParams(values: Partial<T>): string {
|
|
411
495
|
const parts: string[] = [];
|
|
412
496
|
|
|
413
497
|
for (const prop of Object.keys(codecMap)) {
|
|
@@ -427,28 +511,10 @@ function buildDefinition<T extends Record<string, unknown>>(
|
|
|
427
511
|
|
|
428
512
|
// ---- href ----
|
|
429
513
|
function href(pathname: string, values: Partial<T>): string {
|
|
430
|
-
const qs =
|
|
514
|
+
const qs = buildSearchParams(values);
|
|
431
515
|
return qs ? `${pathname}?${qs}` : pathname;
|
|
432
516
|
}
|
|
433
517
|
|
|
434
|
-
// ---- toSearchParams ----
|
|
435
|
-
function toSearchParams(values: Partial<T>): URLSearchParams {
|
|
436
|
-
const usp = new URLSearchParams();
|
|
437
|
-
|
|
438
|
-
for (const prop of Object.keys(codecMap)) {
|
|
439
|
-
if (!(prop in values)) continue;
|
|
440
|
-
const codec = codecMap[prop as keyof T] as SearchParamCodec<unknown>;
|
|
441
|
-
const serialized = codec.serialize(values[prop as keyof T] as unknown);
|
|
442
|
-
|
|
443
|
-
if (serialized === defaultSerialized[prop]) continue;
|
|
444
|
-
if (serialized === null) continue;
|
|
445
|
-
|
|
446
|
-
usp.set(getUrlKey(prop), serialized);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
return usp;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
518
|
// ---- extend ----
|
|
453
519
|
function extend<U extends Record<string, SearchParamCodec<unknown> | StandardSchemaV1<unknown>>>(
|
|
454
520
|
newCodecs: U
|
|
@@ -542,9 +608,8 @@ function buildDefinition<T extends Record<string, unknown>>(
|
|
|
542
608
|
useQueryStates,
|
|
543
609
|
extend,
|
|
544
610
|
pick,
|
|
545
|
-
serialize,
|
|
546
611
|
href,
|
|
547
|
-
|
|
612
|
+
buildSearchParams,
|
|
548
613
|
codecs: codecMap,
|
|
549
614
|
urlKeys: Object.freeze({ ...urlKeys }),
|
|
550
615
|
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parseTotal — invoke a codec over the FULL raw domain.
|
|
3
|
+
*
|
|
4
|
+
* `SearchParamCodec.parse` is documented to be total over
|
|
5
|
+
* `string | string[] | undefined`, because that is exactly what a URL
|
|
6
|
+
* hands it: a param can be absent (`undefined`) or repeated (`string[]`).
|
|
7
|
+
* Timber's own codecs and the Standard Schema bridges honour that.
|
|
8
|
+
*
|
|
9
|
+
* nuqs parsers do not. Their `parse` expects a **present scalar string** —
|
|
10
|
+
* nuqs checks presence itself before ever calling it — so `parseAsBoolean`
|
|
11
|
+
* and `parseAsIsoDate` threw a render-phase 500 on an absent param and
|
|
12
|
+
* `parseAsString` returned an array on a repeated one (TIM-1350).
|
|
13
|
+
*
|
|
14
|
+
* nuqs ships the missing adapter: every parser builder exposes
|
|
15
|
+
* `parseServerSide(value: string | string[] | undefined)`, which maps
|
|
16
|
+
* absent → `null` (or the parser's `withDefault` value), takes the FIRST
|
|
17
|
+
* entry of a repeated param (matching `URLSearchParams.get()`), and wraps
|
|
18
|
+
* the inner `parse` so a throw becomes `null`. That is precisely timber's
|
|
19
|
+
* domain, so we call it in preference to `parse` rather than hand-rolling
|
|
20
|
+
* a second normalization that could disagree with the client hook.
|
|
21
|
+
*
|
|
22
|
+
* Feature detection, not an instanceof check: any codec MAY publish
|
|
23
|
+
* `parseServerSide` to declare "this is my total entry point" — it is an
|
|
24
|
+
* optional member of `SearchParamCodec` — and a codec that does not is
|
|
25
|
+
* assumed already total and called through `parse`. Timber codecs and
|
|
26
|
+
* schema bridges take the second branch untouched; several of them rely on
|
|
27
|
+
* `parse(undefined)` to produce their default.
|
|
28
|
+
*
|
|
29
|
+
* **Search params only.** Segment params (`server/param-coercion.ts`) call
|
|
30
|
+
* `codec.parse` directly and must keep doing so: their domain is a value
|
|
31
|
+
* the router matched, never absent, and a codec that REJECTS one is how a
|
|
32
|
+
* route produces a 404. Routing a rejection through nuqs's `safeParse`
|
|
33
|
+
* would turn that 404 into a silent `null` param. The two domains differ
|
|
34
|
+
* in what "no value" means, not just in plumbing. Cookies are a third
|
|
35
|
+
* domain (`cookies/define-cookie.ts`) and are likewise untouched.
|
|
36
|
+
*
|
|
37
|
+
* `parseServerSide` carries a `@deprecated` tag in nuqs (it steers users to
|
|
38
|
+
* loaders, which timber does not use). It remains public, typed and
|
|
39
|
+
* exercised; `tests/nuqs-codec-boundary.test.ts` asserts totality for every
|
|
40
|
+
* parser through timber's own API, so a nuqs release that drops it fails
|
|
41
|
+
* loudly rather than silently reinstating the 500s.
|
|
42
|
+
*
|
|
43
|
+
* Design doc: design/23-search-params.md §"nuqs parsers, made total"
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import type { Codec } from '../codec.js';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A codec that publishes a total entry point over the raw URL domain.
|
|
50
|
+
*
|
|
51
|
+
* The return is `T`, not `T | null`: this is the entry point timber calls,
|
|
52
|
+
* so whatever it answers IS the field's type. A `null` for an absent param
|
|
53
|
+
* belongs in `T` — a bare nuqs parser is a codec of `string | null`, and
|
|
54
|
+
* `parseAsInteger.withDefault(1)` is a codec of `number`, because nuqs
|
|
55
|
+
* narrows its own `parseServerSide` return to `NonNullable<T>`. Declaring
|
|
56
|
+
* `T | null` here would let a codec annotated `SearchParamCodec<string>`
|
|
57
|
+
* hand back `null` under a non-nullable type (TIM-1350 review).
|
|
58
|
+
*/
|
|
59
|
+
export interface TotalCodec<T> {
|
|
60
|
+
parseServerSide(value: string | string[] | undefined): T;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasParseServerSide<T>(codec: Codec<T>): codec is Codec<T> & TotalCodec<T> {
|
|
64
|
+
return typeof (codec as Partial<TotalCodec<T>>).parseServerSide === 'function';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse a raw URL value through a codec, using the codec's total entry
|
|
69
|
+
* point when it publishes one.
|
|
70
|
+
*
|
|
71
|
+
* Returns `T`, from both branches. A `null` for an absent param is part of
|
|
72
|
+
* the codec's own `T` — see TotalCodec above — so this signature does not
|
|
73
|
+
* widen it, and a caller that must handle "no value" (`withDefault`) sees
|
|
74
|
+
* it because `T` carries it.
|
|
75
|
+
*/
|
|
76
|
+
export function parseTotal<T>(codec: Codec<T>, raw: string | string[] | undefined): T {
|
|
77
|
+
return hasParseServerSide(codec) ? codec.parseServerSide(raw) : codec.parse(raw);
|
|
78
|
+
}
|