@timber-js/app 0.2.0-alpha.165 → 0.2.0-alpha.166

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/_chunks/{actions-CSDD6x7U.js → actions-CDPfMp_I.js} +3 -3
  2. package/dist/_chunks/{actions-CSDD6x7U.js.map → actions-CDPfMp_I.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-eb1gydM7.js → cache-api-DygSeKCB.js} +2 -2
  4. package/dist/_chunks/{cache-api-eb1gydM7.js.map → cache-api-DygSeKCB.js.map} +1 -1
  5. package/dist/_chunks/{cli-schema-sync-mGfRbjh2.js → cli-schema-sync-EXGYPhI2.js} +3 -3
  6. package/dist/_chunks/{cli-schema-sync-mGfRbjh2.js.map → cli-schema-sync-EXGYPhI2.js.map} +1 -1
  7. package/dist/_chunks/{define-CFmvb4Bt.js → define-COtkxMRT.js} +16 -28
  8. package/dist/_chunks/define-COtkxMRT.js.map +1 -0
  9. package/dist/_chunks/{define-Bssfp6ot.js → define-c4au4I9R.js} +2 -2
  10. package/dist/_chunks/{define-Bssfp6ot.js.map → define-c4au4I9R.js.map} +1 -1
  11. package/dist/_chunks/{logger-B_O6-mdJ.js → logger-t3uxAmbX.js} +3 -3
  12. package/dist/_chunks/{logger-B_O6-mdJ.js.map → logger-t3uxAmbX.js.map} +1 -1
  13. package/dist/_chunks/{plugin-context-BnaiU_cF.js → plugin-context---kTF5v8.js} +2 -2
  14. package/dist/_chunks/{plugin-context-BnaiU_cF.js.map → plugin-context---kTF5v8.js.map} +1 -1
  15. package/dist/_chunks/{resolve-schema-3iUvBV5T.js → resolve-schema-Dz3fcFUo.js} +2 -2
  16. package/dist/_chunks/{resolve-schema-3iUvBV5T.js.map → resolve-schema-Dz3fcFUo.js.map} +1 -1
  17. package/dist/_chunks/{schema-bridge-BY3QLBL7.js → schema-bridge-DT_Tn0Xf.js} +2 -2
  18. package/dist/_chunks/{schema-bridge-BY3QLBL7.js.map → schema-bridge-DT_Tn0Xf.js.map} +1 -1
  19. package/dist/_chunks/{use-query-states-CbeQmext.js → use-query-states-DFvWd-EA.js} +65 -7
  20. package/dist/_chunks/use-query-states-DFvWd-EA.js.map +1 -0
  21. package/dist/_chunks/{walkers-BL3MCMgO.js → walkers-DBVzXuWc.js} +3 -3
  22. package/dist/_chunks/{walkers-BL3MCMgO.js.map → walkers-DBVzXuWc.js.map} +1 -1
  23. package/dist/adapters/nitro.js +1 -1
  24. package/dist/cache/index.js +1 -1
  25. package/dist/cli.js +2 -2
  26. package/dist/client/internal.js +1 -1
  27. package/dist/client/use-query-states.d.ts.map +1 -1
  28. package/dist/codec.js +1 -1
  29. package/dist/cookies/index.js +1 -1
  30. package/dist/dev-tools/logs.d.ts +15 -1
  31. package/dist/dev-tools/logs.d.ts.map +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +104 -20
  34. package/dist/index.js.map +1 -1
  35. package/dist/params/index.js +1 -1
  36. package/dist/routing/index.js +2 -2
  37. package/dist/schema-bridge.d.ts.map +1 -1
  38. package/dist/search-params/define.d.ts +35 -4
  39. package/dist/search-params/define.d.ts.map +1 -1
  40. package/dist/search-params/index.js +3 -7
  41. package/dist/search-params/index.js.map +1 -1
  42. package/dist/search-params/wrappers.d.ts +2 -2
  43. package/dist/search-params/wrappers.d.ts.map +1 -1
  44. package/dist/segment-params/index.js +1 -1
  45. package/dist/server/index.js +2 -2
  46. package/dist/server/internal.js +8 -6
  47. package/dist/server/internal.js.map +1 -1
  48. package/dist/server/stream-utils.d.ts.map +1 -1
  49. package/docs/api/33-api-search-params.mdx +13 -0
  50. package/docs/learn/00-introduction.mdx +1 -2
  51. package/package.json +8 -7
  52. package/src/cli.ts +0 -0
  53. package/src/client/use-query-states.ts +99 -7
  54. package/src/dev-tools/logs.ts +119 -34
  55. package/src/index.ts +14 -1
  56. package/src/schema-bridge.ts +4 -3
  57. package/src/search-params/define.ts +57 -51
  58. package/src/search-params/wrappers.ts +17 -26
  59. package/src/server/stream-utils.ts +8 -6
  60. package/LICENSE +0 -8
  61. package/dist/_chunks/define-CFmvb4Bt.js.map +0 -1
  62. package/dist/_chunks/use-query-states-CbeQmext.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"stream-utils.d.ts","sourceRoot":"","sources":["../../src/server/stream-utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,MAAM,WAAW,UAAU;IACzB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,OAAO,CAAC,EAAE,UAAU,GACnB,CAAC,cAAc,CAAC,UAAU,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC,CAmK1D;AAED;;;;;;;;;GASG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,EAChC,SAAS,EAAE,MAAM,GAChB,cAAc,CAAC,UAAU,CAAC,CA0D5B"}
1
+ {"version":3,"file":"stream-utils.d.ts","sourceRoot":"","sources":["../../src/server/stream-utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,MAAM,WAAW,UAAU;IACzB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,OAAO,CAAC,EAAE,UAAU,GACnB,CAAC,cAAc,CAAC,UAAU,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC,CAmK1D;AAED;;;;;;;;;GASG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,EAChC,SAAS,EAAE,MAAM,GAChB,cAAc,CAAC,UAAU,CAAC,CA4D5B"}
@@ -23,6 +23,19 @@ export const searchParams = defineSearchParams({
23
23
  });
24
24
  ```
25
25
 
26
+ ### Implicit optionality
27
+
28
+ Search params are optional by nature — the URL might not contain them. A schema without `.default()` or `.optional()` (e.g. bare `z.string()`) is treated as implicitly optional: absent or invalid input parses to `undefined`, and the field's inferred type widens to `T | undefined`. Definitions never throw at module-eval time, and schema-backed parsing never throws at request time.
29
+
30
+ ```ts
31
+ const def = defineSearchParams({ name: z.string() });
32
+ def.parse(new URLSearchParams('')); // { name: undefined }
33
+ def.parse(new URLSearchParams('name=x')); // { name: 'x' }
34
+ // Inferred type: { name: string | undefined }
35
+ ```
36
+
37
+ Note: `z.coerce.*` schemas declare input `unknown`, so a coerce schema without `.default()` keeps its narrow type even though absent input yields `undefined` at runtime — add `.default()` to coerce schemas for accurate types.
38
+
26
39
  ### Returns: `SearchParamsDefinition<T>`
27
40
 
28
41
  | Method / Property | Description |
@@ -61,8 +61,7 @@ export const searchParams = defineSearchParams({
61
61
  const sp = searchParams.get();
62
62
 
63
63
  // or on the client
64
-
65
- const [sp, setSearchParams] = searchParams.useQueryState();
64
+ const [sp, setSearchParams] = searchParams.useQueryStates();
66
65
  ```
67
66
 
68
67
  ### Typed Routes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.165",
3
+ "version": "0.2.0-alpha.166",
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",
@@ -150,6 +150,12 @@
150
150
  "publishConfig": {
151
151
  "access": "public"
152
152
  },
153
+ "scripts": {
154
+ "copy-docs": "node scripts/copy-docs.js",
155
+ "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist && pnpm run copy-docs",
156
+ "typecheck": "tsgo --noEmit",
157
+ "prepublishOnly": "pnpm run build"
158
+ },
153
159
  "dependencies": {
154
160
  "@opentelemetry/api": "^1.9.1",
155
161
  "@opentelemetry/context-async-hooks": "^2.8.0",
@@ -195,10 +201,5 @@
195
201
  },
196
202
  "engines": {
197
203
  "node": ">=22.18.0"
198
- },
199
- "scripts": {
200
- "copy-docs": "node scripts/copy-docs.js",
201
- "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist && pnpm run copy-docs",
202
- "typecheck": "tsgo --noEmit"
203
204
  }
204
- }
205
+ }
package/src/cli.ts CHANGED
File without changes
@@ -22,19 +22,71 @@ import { getSearchParamsDefinition } from '../search-params/registry.js';
22
22
 
23
23
  // ─── Codec Bridge ─────────────────────────────────────────────────
24
24
 
25
+ // nuqs's parser contract conflates values timber codecs distinguish:
26
+ // parse() returning null means "unparseable, substitute defaultValue",
27
+ // and undefined entries are skipped entirely. Timber codecs can
28
+ // legitimately produce both — bare z.string() yields undefined for absent
29
+ // params (implicit optionality), and a codec may map a present value to
30
+ // null. Wrap those two values in sentinels across the nuqs boundary and
31
+ // unwrap them before handing values back to the caller, so the client
32
+ // hook returns exactly what server-side parse() returns.
33
+ // Unique object references compared by identity — a codec can never
34
+ // produce these from URL input, so user-controlled strings cannot collide
35
+ // with them (unlike string sentinels), and unlike Symbols they survive
36
+ // nuqs's internal string coercion without throwing.
37
+ const NULL_SENTINEL: object = { timberSentinel: 'null' };
38
+ const UNDEFINED_SENTINEL: object = { timberSentinel: 'undefined' };
39
+
40
+ function wrapNuqsValue(value: unknown): unknown {
41
+ if (value === null) return NULL_SENTINEL;
42
+ if (value === undefined) return UNDEFINED_SENTINEL;
43
+ return value;
44
+ }
45
+
46
+ function unwrapNuqsValue(value: unknown): unknown {
47
+ if (value === NULL_SENTINEL) return null;
48
+ if (value === UNDEFINED_SENTINEL) return undefined;
49
+ return value;
50
+ }
51
+
25
52
  /**
26
53
  * Bridge a timber SearchParamCodec to a nuqs-compatible SingleParser.
27
54
  *
28
55
  * nuqs parsers: { parse(string) → T|null, serialize?(T) → string, eq?, defaultValue? }
29
56
  * timber codecs: { parse(string|string[]|undefined) → T, serialize(T) → string|null }
57
+ *
58
+ * The defaultValue is computed eagerly. Codecs are documented to return a
59
+ * default rather than throw, but a throwing codec must not crash every
60
+ * component that mounts the hook — treat its default as undefined and let
61
+ * its error surface from server-side parse() instead.
30
62
  */
31
63
  function bridgeCodec<T>(codec: SearchParamCodec<T>): SingleParser<T> & { defaultValue: T } {
64
+ let defaultValue: unknown;
65
+ try {
66
+ defaultValue = codec.parse(undefined);
67
+ } catch {
68
+ defaultValue = undefined;
69
+ }
32
70
  return {
33
- parse: (v: string) => codec.parse(v),
34
- serialize: (v: T) => codec.serialize(v) ?? '',
35
- defaultValue: codec.parse(undefined) as T,
36
- eq: (a: T, b: T) => codec.serialize(a) === codec.serialize(b),
37
- };
71
+ parse: (v: string) => wrapNuqsValue(codec.parse(v)),
72
+ serialize: (v: unknown) => {
73
+ const value = unwrapNuqsValue(v);
74
+ // Delegate null to the codec — some codecs encode null as a real
75
+ // query value. undefined has no encoding; nuqs requires a string.
76
+ return value === undefined ? '' : (codec.serialize(value as T) ?? '');
77
+ },
78
+ defaultValue: wrapNuqsValue(defaultValue),
79
+ eq: (a: unknown, b: unknown) => {
80
+ if (a === b) return true;
81
+ try {
82
+ return (
83
+ codec.serialize(unwrapNuqsValue(a) as T) === codec.serialize(unwrapNuqsValue(b) as T)
84
+ );
85
+ } catch {
86
+ return false;
87
+ }
88
+ },
89
+ } as SingleParser<T> & { defaultValue: T };
38
90
  }
39
91
 
40
92
  /**
@@ -124,6 +176,19 @@ export function useQueryStates<T extends Record<string, unknown>>(
124
176
  throw err;
125
177
  }
126
178
 
179
+ // Unwrap the null/undefined sentinels the bridge injected (see Codec
180
+ // Bridge above) so callers see exactly what server-side parse() returns.
181
+ // Copy-on-write preserves the identity of nuqs's memoized values object
182
+ // when nothing needs unwrapping.
183
+ let normalized = values;
184
+ for (const key of Object.keys(bridged)) {
185
+ const value = normalized[key];
186
+ if (value === NULL_SENTINEL || value === UNDEFINED_SENTINEL) {
187
+ if (normalized === values) normalized = { ...values };
188
+ normalized[key] = unwrapNuqsValue(value);
189
+ }
190
+ }
191
+
127
192
  // Wrap the nuqs setter to match timber's SetParams<T> signature.
128
193
  // nuqs's setter accepts Partial<Nullable<Values>> | UpdaterFn | null.
129
194
  // timber's setter accepts Partial<T> with optional SetParamsOptions.
@@ -132,11 +197,38 @@ export function useQueryStates<T extends Record<string, unknown>>(
132
197
  if (setOptions?.shallow !== undefined) nuqsSetOptions.shallow = setOptions.shallow;
133
198
  if (setOptions?.scroll !== undefined) nuqsSetOptions.scroll = setOptions.scroll;
134
199
  if (setOptions?.history !== undefined) nuqsSetOptions.history = setOptions.history;
200
+ // nuqs's update loop skips undefined entries and treats null as a
201
+ // key deletion before serialize runs. Timber semantics:
202
+ // - setParams({ q: undefined }) must clear ?q= (absent = undefined),
203
+ // so explicit undefined maps to a null deletion.
204
+ // - setParams({ q: null }) clears the key only when the codec encodes
205
+ // null as "omit" (serialize(null) === null). If the codec encodes
206
+ // null as a real query value, forward the sentinel so the bridged
207
+ // serialize writes it — matching definition.serialize({ q: null }).
208
+ let forwarded: Record<string, unknown> = partial;
209
+ for (const key of Object.keys(partial)) {
210
+ const value = partial[key as keyof T];
211
+ if (value === undefined) {
212
+ if (forwarded === partial) forwarded = { ...partial };
213
+ forwarded[key] = null;
214
+ } else if (value === null) {
215
+ let encoded: string | null = null;
216
+ try {
217
+ encoded = codecs[key as keyof T]?.serialize(null as T[keyof T]) ?? null;
218
+ } catch {
219
+ // Codec can't serialize null — treat as a deletion.
220
+ }
221
+ if (encoded !== null) {
222
+ if (forwarded === partial) forwarded = { ...partial };
223
+ forwarded[key] = NULL_SENTINEL;
224
+ }
225
+ }
226
+ }
135
227
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
- void setValues(partial as any, nuqsSetOptions);
228
+ void setValues(forwarded as any, nuqsSetOptions);
137
229
  };
138
230
 
139
- return [values as T, setParams];
231
+ return [normalized as T, setParams];
140
232
  }
141
233
 
142
234
  // ─── Definition binding ───────────────────────────────────────────
@@ -12,9 +12,86 @@
12
12
  * Design docs: 18-build-system.md §"Dev Server", 02-rendering-pipeline.md
13
13
  */
14
14
 
15
- import type { Plugin, ViteDevServer } from 'vite';
15
+ import type { Plugin, ViteDevServer, Logger } from 'vite';
16
16
  import type { PluginContext } from '../plugin-context.js';
17
17
 
18
+ // ─── Original Console Snapshot ──────────────────────────────────────────
19
+
20
+ /**
21
+ * Snapshot of the original console methods, captured at module load time
22
+ * (before any patching). Used as Vite's `customLogger` console so that
23
+ * forwardConsole output bypasses our patch entirely — eliminating the
24
+ * server→browser→server forwarding loop without string matching.
25
+ */
26
+ export const originalConsole: Pick<Console, 'log' | 'warn' | 'error' | 'debug' | 'info'> = {
27
+ log: console.log.bind(console),
28
+ warn: console.warn.bind(console),
29
+ error: console.error.bind(console),
30
+ debug: console.debug.bind(console),
31
+ info: console.info.bind(console),
32
+ };
33
+
34
+ /**
35
+ * Wrap a Vite Logger so its output methods route through `originalConsole`
36
+ * instead of the global (potentially patched) console. This prevents the
37
+ * server→browser→server forwarding loop even when the user supplies their
38
+ * own `customLogger` that internally calls console methods.
39
+ */
40
+ export function wrapLoggerWithOriginalConsole(logger: Logger): Logger {
41
+ // Swap all patched console methods to originals during any logger call,
42
+ // since user loggers may call any combination of console methods internally.
43
+ function withOriginals<T>(fn: () => T): T {
44
+ const saved = {
45
+ log: console.log,
46
+ warn: console.warn,
47
+ error: console.error,
48
+ debug: console.debug,
49
+ info: console.info,
50
+ };
51
+ console.log = originalConsole.log;
52
+ console.warn = originalConsole.warn;
53
+ console.error = originalConsole.error;
54
+ console.debug = originalConsole.debug;
55
+ console.info = originalConsole.info;
56
+ try {
57
+ return fn();
58
+ } finally {
59
+ console.log = saved.log;
60
+ console.warn = saved.warn;
61
+ console.error = saved.error;
62
+ console.debug = saved.debug;
63
+ console.info = saved.info;
64
+ }
65
+ }
66
+
67
+ return {
68
+ get hasWarned() {
69
+ return logger.hasWarned;
70
+ },
71
+ set hasWarned(v) {
72
+ logger.hasWarned = v;
73
+ },
74
+ info(msg, opts) {
75
+ withOriginals(() => logger.info(msg, opts));
76
+ },
77
+ warn(msg, opts) {
78
+ withOriginals(() => logger.warn(msg, opts));
79
+ },
80
+ warnOnce(msg, opts) {
81
+ withOriginals(() => logger.warnOnce(msg, opts));
82
+ },
83
+ error(msg, opts) {
84
+ withOriginals(() => logger.error(msg, opts));
85
+ },
86
+ clearScreen(type) {
87
+ logger.clearScreen(type);
88
+ },
89
+ hasErrorLogged(error) {
90
+ return logger.hasErrorLogged(error);
91
+ },
92
+ };
93
+ }
94
+
18
95
  // ─── Types ───────────────────────────────────────────────────────────────
19
96
 
20
97
  /** Log levels that are patched and forwarded. */
@@ -215,50 +292,58 @@ export function isFrameworkInternalCaller(): boolean {
215
292
  * 3. Sends via server.hot.send() — dropped if no clients connected
216
293
  */
217
294
  function patchConsole(server: ViteDevServer, projectRoot: string): () => void {
218
- const originals = new Map<ServerLogLevel, (...args: unknown[]) => void>();
295
+ const prevConsole = new Map<ServerLogLevel, (...args: unknown[]) => void>();
296
+ // Per-level guard so a console.warn inside a console.error handler isn't
297
+ // suppressed — only same-level synchronous re-entrancy is blocked.
298
+ const patchingLevel = new Set<ServerLogLevel>();
219
299
 
220
300
  for (const level of LOG_LEVELS) {
221
- originals.set(level, console[level].bind(console));
301
+ prevConsole.set(level, console[level].bind(console));
222
302
 
223
303
  console[level] = (...args: unknown[]) => {
224
- // Always call the original — server terminal output is preserved
225
- originals.get(level)!(...args);
226
-
227
- // Skip framework-internal logs (plugins/, adapters/) from browser forwarding.
228
- // Server runtime logs (render errors, action errors, etc.) are preserved.
229
- if (isFrameworkInternalCaller()) return;
230
-
231
- // Break the server→browser→server console forwarding loop. Vite's
232
- // forwardConsole re-logs browser console output on the server via
233
- // `logger.error("[console.error] ...")`. Our patch would send it back
234
- // to the browser, Vite's client patch forwards it again → exponential
235
- // log spam. Detect the `[console.<level>]` prefix Vite adds and skip.
236
- if (typeof args[0] === 'string' && args[0].includes('[console.')) return;
304
+ // Same-level re-entrancy guard: if prevConsole's wrapper calls back
305
+ // into console[level], just pass through without forwarding.
306
+ if (patchingLevel.has(level)) {
307
+ prevConsole.get(level)!(...args);
308
+ return;
309
+ }
237
310
 
238
- // Serialize and forward to browser
311
+ patchingLevel.add(level);
239
312
  try {
240
- const payload: ServerLogPayload = {
241
- level,
242
- args: args.map((arg) => serializeArg(arg)),
243
- location: extractCallerLocation(projectRoot),
244
- timestamp: Date.now(),
245
- };
246
-
247
- server.hot.send('timber:server-log', payload);
248
- } catch (e) {
249
- // Use the original console.debug to avoid re-entering the patched handler
250
- originals.get('debug')!(
251
- '[timber] server log forwarding failed:',
252
- e instanceof Error ? e.message : e
253
- );
313
+ // Call the previous console method first — guarantees terminal output
314
+ // even if serialization or forwarding fails downstream. If prevConsole
315
+ // throws, the error propagates (not swallowed) but patchingLevel is
316
+ // still cleaned up by the outer finally.
317
+ prevConsole.get(level)!(...args);
318
+
319
+ // Skip framework-internal logs (plugins/, adapters/) from browser forwarding.
320
+ if (isFrameworkInternalCaller()) return;
321
+
322
+ try {
323
+ const payload: ServerLogPayload = {
324
+ level,
325
+ args: args.map((arg) => serializeArg(arg)),
326
+ location: extractCallerLocation(projectRoot),
327
+ timestamp: Date.now(),
328
+ };
329
+
330
+ server.hot.send('timber:server-log', payload);
331
+ } catch (e) {
332
+ originalConsole.debug(
333
+ '[timber] server log forwarding failed:',
334
+ e instanceof Error ? e.message : e
335
+ );
336
+ }
337
+ } finally {
338
+ patchingLevel.delete(level);
254
339
  }
255
340
  };
256
341
  }
257
342
 
258
- // Return a cleanup function to restore originals
343
+ // Return a cleanup function to restore the previous console methods
259
344
  return () => {
260
- for (const [level, original] of originals) {
261
- console[level] = original;
345
+ for (const [level, prev] of prevConsole) {
346
+ console[level] = prev;
262
347
  }
263
348
  };
264
349
  }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { Plugin, PluginOption } from 'vite';
14
+ import { createLogger } from 'vite';
14
15
  import { join, relative, resolve } from 'node:path';
15
16
  import { createRequire } from 'node:module';
16
17
  import react, { reactCompilerPreset } from '@vitejs/plugin-react';
@@ -23,7 +24,7 @@ import { timberShims } from './plugins/shims';
23
24
  import { timberFonts } from './plugins/fonts';
24
25
  import { timberStaticBuild } from './plugins/static-build';
25
26
  import { timberBuildManifest } from './plugins/build-manifest';
26
- import { timberDevLogs } from './dev-tools/logs';
27
+ import { timberDevLogs, originalConsole, wrapLoggerWithOriginalConsole } from './dev-tools/logs';
27
28
  import { resolveForwardConsole } from './dev-tools/browser-logs';
28
29
  import { timberReactProd } from './plugins/react-prod';
29
30
  import { timberChunks } from './plugins/chunks';
@@ -438,10 +439,22 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
438
439
  // on the devBrowserLogs config (default: 'warn'). See TIM-1152.
439
440
  serverConfig.forwardConsole = resolveForwardConsole(ctx.config.devBrowserLogs);
440
441
 
442
+ // Provide a customLogger whose output routes through the pre-patched
443
+ // console methods so Vite's forwardConsole output never re-enters our
444
+ // server→browser patch. If the user supplied their own customLogger,
445
+ // wrap it so its console calls also bypass patching.
446
+ const customLogger = userConfig.customLogger
447
+ ? wrapLoggerWithOriginalConsole(userConfig.customLogger)
448
+ : createLogger(userConfig.logLevel ?? 'info', {
449
+ allowClearScreen: userConfig.clearScreen ?? true,
450
+ console: originalConsole as Console,
451
+ });
452
+
441
453
  return {
442
454
  build: { outDir: buildOutDir },
443
455
  environments: envOutDirs,
444
456
  optimizeDeps: { exclude: timberOptimizeDepsExclude },
457
+ customLogger,
445
458
  server: serverConfig,
446
459
  preview: previewConfig,
447
460
  };
@@ -184,9 +184,10 @@ export function fromSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
184
184
  return defaultResult.value;
185
185
  }
186
186
 
187
- // No default available — return undefined (codec design choice).
188
- // Callers like defineSearchParams validate this at definition time
189
- // via validateSchemaDefaults() to catch it early.
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"
190
191
  return undefined as T;
191
192
  },
192
193
 
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { useQueryStates as clientUseQueryStates } from '../client/use-query-states.js';
14
- import { fromSchema, isStandardSchema, isCodec, validateSync } from '../schema-bridge.js';
14
+ import { fromSchema, isStandardSchema, isCodec } from '../schema-bridge.js';
15
15
  import type { StandardSchemaV1 } from '../schema-bridge.js';
16
16
  import type { Codec } from '../codec.js';
17
17
 
@@ -165,9 +165,43 @@ export type { StandardSchemaV1 } from '../schema-bridge.js';
165
165
  // Type-level helpers
166
166
  // ---------------------------------------------------------------------------
167
167
 
168
- /** Infer the output type from either a SearchParamCodec or a StandardSchemaV1. */
168
+ /**
169
+ * Extract a Standard Schema's declared *input* type from its optional
170
+ * `~standard.types` property (part of the Standard Schema spec; Zod, Valibot,
171
+ * and ArkType all declare it at the type level). Falls back to `never` when
172
+ * the schema doesn't declare types (e.g. hand-written schemas): with no
173
+ * metadata we can't know whether the schema handles `undefined`, so InferField
174
+ * widens conservatively rather than risk a type lie.
175
+ */
176
+ type InferSchemaInput<V> = V extends { '~standard': { types?: infer TS } }
177
+ ? [NonNullable<TS>] extends [{ input: infer I }]
178
+ ? I
179
+ : never
180
+ : never;
181
+
182
+ /**
183
+ * Infer the output type from either a SearchParamCodec or a StandardSchemaV1.
184
+ *
185
+ * Schemas whose input type rejects `undefined` (e.g. bare `z.string()`, whose
186
+ * input is `string`) are implicitly optional: the URL might not contain the
187
+ * param, and fromSchema returns `undefined` when the schema rejects absent
188
+ * input and has no default. The output type widens to `T | undefined` so the
189
+ * type doesn't lie. Schemas that accept `undefined` input (`.optional()`,
190
+ * `.default()`) keep their declared output type.
191
+ *
192
+ * Limitation: `z.coerce.*` schemas declare input `unknown`, which accepts
193
+ * `undefined` at the type level — so a coerce schema without `.default()`
194
+ * keeps its narrow output type even though absent input yields `undefined`
195
+ * at runtime. Add `.default()` to coerce schemas for accurate types.
196
+ */
169
197
  export type InferField<V> =
170
- V extends SearchParamCodec<infer T> ? T : V extends StandardSchemaV1<infer T> ? T : never;
198
+ V extends SearchParamCodec<infer T>
199
+ ? T
200
+ : V extends StandardSchemaV1<infer T>
201
+ ? undefined extends InferSchemaInput<V>
202
+ ? T
203
+ : T | undefined
204
+ : never;
171
205
 
172
206
  /** Acceptable field value for defineSearchParams: a codec or a Standard Schema. */
173
207
  export type SearchParamField<T = unknown> = SearchParamCodec<T> | StandardSchemaV1<T>;
@@ -198,9 +232,17 @@ function normalizeRaw(
198
232
  * Compute the serialized default value for a codec. Used for
199
233
  * default-omission: when serialize(value) === serialize(parse(undefined)),
200
234
  * the field is omitted from the URL.
235
+ *
236
+ * Codecs are documented to return a default rather than throw, but a
237
+ * hand-written codec that throws on absent input must not turn definition
238
+ * into a crash — treat its default as null (nothing to omit).
201
239
  */
202
240
  function getDefaultSerialized<T>(codec: SearchParamCodec<T>): string | null {
203
- return codec.serialize(codec.parse(undefined));
241
+ try {
242
+ return codec.serialize(codec.parse(undefined));
243
+ } catch {
244
+ return null;
245
+ }
204
246
  }
205
247
 
206
248
  // isStandardSchema and isCodec are imported from schema-bridge.ts.
@@ -218,22 +260,11 @@ function resolveField(
218
260
  return { codec: value, urlKey: value.urlKey };
219
261
  }
220
262
 
221
- // Auto-detect Standard Schema
263
+ // Auto-detect Standard Schema. Schemas that reject undefined input and
264
+ // have no default are implicitly optional: fromSchema returns undefined
265
+ // for absent params, and InferField widens the output type to
266
+ // T | undefined. design/23-search-params.md §"Implicit Optionality"
222
267
  if (isStandardSchema(value)) {
223
- // Validate that the schema handles absent params (undefined input).
224
- // Search params are optional — the URL might not contain the param.
225
- // If the schema rejects undefined and has no default, the codec would
226
- // return `undefined as T`, making the type lie. Catch this at definition
227
- // time instead. design/23-search-params.md §"Default Validation"
228
- const absentCheck = validateSync(value, undefined);
229
- if (absentCheck.issues) {
230
- throw new Error(
231
- `[timber] defineSearchParams: field '${fieldName}' has no default value and rejects undefined input.\n` +
232
- ` Search params are optional — the URL might not contain ?${fieldName}=anything.\n` +
233
- ` Add .default() to your schema: z.coerce.number().int().min(1).default(1)\n` +
234
- ` Or use .optional() if the field is truly optional.`
235
- );
236
- }
237
268
  return { codec: fromSchema(value) };
238
269
  }
239
270
 
@@ -244,25 +275,6 @@ function resolveField(
244
275
  );
245
276
  }
246
277
 
247
- /**
248
- * Validate that all codecs handle absent params (parse(undefined) doesn't throw).
249
- * Catches schemas that throw on missing input. `undefined` and `null` are both
250
- * valid defaults — `undefined` is correct for optional fields (e.g., `z.string().optional()`).
251
- */
252
- function validateDefaults(codecMap: Record<string, SearchParamCodec<unknown>>): void {
253
- for (const [key, codec] of Object.entries(codecMap)) {
254
- try {
255
- codec.parse(undefined);
256
- } catch {
257
- throw new Error(
258
- `[timber] defineSearchParams: field '${key}' throws when the param is absent.\n` +
259
- ` Search params are optional — the URL might not contain ?${key}=anything.\n` +
260
- ` Add .default() or .optional() to your schema, or wrap with withDefault().`
261
- );
262
- }
263
- }
264
- }
265
-
266
278
  // ---------------------------------------------------------------------------
267
279
  // Factory
268
280
  // ---------------------------------------------------------------------------
@@ -297,9 +309,13 @@ function validateDefaults(codecMap: Record<string, SearchParamCodec<unknown>>):
297
309
  * )
298
310
  * ```
299
311
  */
300
- export function defineSearchParams<T extends Record<string, unknown>>(
301
- schema: StandardSchemaV1<T> & { shape: Record<string, StandardSchemaV1<unknown>> }
302
- ): SearchParamsDefinition<T>;
312
+ export function defineSearchParams<
313
+ S extends StandardSchemaV1<Record<string, unknown>> & {
314
+ shape: Record<string, StandardSchemaV1<unknown>>;
315
+ },
316
+ >(
317
+ schema: S
318
+ ): SearchParamsDefinition<{ [K in keyof S['shape'] & string]: InferField<S['shape'][K]> }>;
303
319
 
304
320
  /**
305
321
  * Overload: accept a map of codecs and/or Standard Schema objects.
@@ -357,9 +373,6 @@ function defineSearchParamsFromMap(
357
373
  }
358
374
  }
359
375
 
360
- // Validate that all codecs handle absent params
361
- validateDefaults(resolvedCodecs);
362
-
363
376
  return buildDefinition(resolvedCodecs as unknown as CodecMap<Record<string, unknown>>, urlKeys);
364
377
  }
365
378
 
@@ -484,11 +497,6 @@ function buildDefinition<T extends Record<string, unknown>>(
484
497
  // Merge URL keys: base keys + new codec urlKeys from withUrlKey
485
498
  const combinedUrlKeys: Record<string, string> = { ...urlKeys, ...newUrlKeys };
486
499
 
487
- // Same definition-time guard as defineSearchParams — a throw-on-absent
488
- // codec added via extend() gets the friendly guidance error, not a
489
- // crash on first parse (TIM-1066).
490
- validateDefaults(combinedCodecs as Record<string, SearchParamCodec<unknown>>);
491
-
492
500
  return buildDefinition<Combined>(combinedCodecs, combinedUrlKeys);
493
501
  }
494
502
 
@@ -515,8 +523,6 @@ function buildDefinition<T extends Record<string, unknown>>(
515
523
  }
516
524
  }
517
525
 
518
- validateDefaults(pickedCodecs);
519
-
520
526
  return buildDefinition<Pick<T, K>>(
521
527
  pickedCodecs as unknown as CodecMap<Pick<T, K>>,
522
528
  pickedUrlKeys