@timber-js/app 0.2.0-alpha.190 → 0.2.0-alpha.192

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 (123) hide show
  1. package/dist/_chunks/{actions-35jnMdeJ.js → actions-BjbbNRFN.js} +3 -3
  2. package/dist/_chunks/{actions-35jnMdeJ.js.map → actions-BjbbNRFN.js.map} +1 -1
  3. package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
  4. package/dist/_chunks/als-slots-BEEIPKYm.js.map +1 -1
  5. package/dist/_chunks/{cache-api-DjNrIWRR.js → cache-api-DllJ-Lyw.js} +2 -2
  6. package/dist/_chunks/{cache-api-DjNrIWRR.js.map → cache-api-DllJ-Lyw.js.map} +1 -1
  7. package/dist/_chunks/{cli-check-CpmN7Nh-.js → cli-check-DJZDc22E.js} +3 -3
  8. package/dist/_chunks/{cli-check-CpmN7Nh-.js.map → cli-check-DJZDc22E.js.map} +1 -1
  9. package/dist/_chunks/{cli-schema-sync-CGMp_Psg.js → cli-schema-sync-D4_AZVUb.js} +2 -2
  10. package/dist/_chunks/{cli-schema-sync-CGMp_Psg.js.map → cli-schema-sync-D4_AZVUb.js.map} +1 -1
  11. package/dist/_chunks/{convention-lint-kXsgc_-7.js → convention-lint-CpteIpTm.js} +2 -2
  12. package/dist/_chunks/{convention-lint-kXsgc_-7.js.map → convention-lint-CpteIpTm.js.map} +1 -1
  13. package/dist/_chunks/{logger-D8xJZXIN.js → logger-CH7IcMmg.js} +1 -16
  14. package/dist/_chunks/{logger-D8xJZXIN.js.map → logger-CH7IcMmg.js.map} +1 -1
  15. package/dist/_chunks/{scanner-C8b0Gcw3.js → scanner-CAietmj4.js} +2 -2
  16. package/dist/_chunks/{scanner-C8b0Gcw3.js.map → scanner-CAietmj4.js.map} +1 -1
  17. package/dist/_chunks/segment-context-CjOlyB8Y.js.map +1 -1
  18. package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -1
  19. package/dist/_chunks/slot-params-BCTmZkQB.js.map +1 -1
  20. package/dist/_chunks/ssr-data-14MXm7Pj.js.map +1 -1
  21. package/dist/_chunks/use-segment-params-C4r4BD9T.js.map +1 -1
  22. package/dist/_chunks/{walkers-RzN6AFjr.js → walkers-BsVLmD1S.js} +2 -2
  23. package/dist/_chunks/{walkers-RzN6AFjr.js.map → walkers-BsVLmD1S.js.map} +1 -1
  24. package/dist/cache/index.js +1 -1
  25. package/dist/cli.js +2 -2
  26. package/dist/client/internal.js.map +1 -1
  27. package/dist/client/params-context.d.ts +2 -1
  28. package/dist/client/params-context.d.ts.map +1 -1
  29. package/dist/client/ssr-data.d.ts +2 -1
  30. package/dist/client/ssr-data.d.ts.map +1 -1
  31. package/dist/client/state.d.ts +3 -2
  32. package/dist/client/state.d.ts.map +1 -1
  33. package/dist/client/use-segment-params.d.ts +5 -4
  34. package/dist/client/use-segment-params.d.ts.map +1 -1
  35. package/dist/config-types.d.ts +24 -6
  36. package/dist/config-types.d.ts.map +1 -1
  37. package/dist/index.js +35 -32
  38. package/dist/index.js.map +1 -1
  39. package/dist/plugins/mdx.d.ts +17 -4
  40. package/dist/plugins/mdx.d.ts.map +1 -1
  41. package/dist/plugins/server-bundle.d.ts.map +1 -1
  42. package/dist/routing/index.js +2 -2
  43. package/dist/routing/segment-keys.d.ts +2 -1
  44. package/dist/routing/segment-keys.d.ts.map +1 -1
  45. package/dist/server/action-handler.d.ts.map +1 -1
  46. package/dist/server/als-registry.d.ts +10 -5
  47. package/dist/server/als-registry.d.ts.map +1 -1
  48. package/dist/server/chain-url-parts.d.ts +2 -1
  49. package/dist/server/chain-url-parts.d.ts.map +1 -1
  50. package/dist/server/index.d.ts +1 -0
  51. package/dist/server/index.d.ts.map +1 -1
  52. package/dist/server/index.js +2 -2
  53. package/dist/server/internal.js +3 -3
  54. package/dist/server/internal.js.map +1 -1
  55. package/dist/server/metadata-collector.d.ts +40 -18
  56. package/dist/server/metadata-collector.d.ts.map +1 -1
  57. package/dist/server/param-coercion.d.ts +2 -1
  58. package/dist/server/param-coercion.d.ts.map +1 -1
  59. package/dist/server/pipeline-phases.d.ts.map +1 -1
  60. package/dist/server/pipeline.d.ts +3 -2
  61. package/dist/server/pipeline.d.ts.map +1 -1
  62. package/dist/server/prebuilt/cache-key.d.ts +2 -1
  63. package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
  64. package/dist/server/prebuilt/payload-source.d.ts +2 -1
  65. package/dist/server/prebuilt/payload-source.d.ts.map +1 -1
  66. package/dist/server/prebuilt/synthetic-store.d.ts +2 -1
  67. package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
  68. package/dist/server/prebuilt-builder.d.ts +4 -3
  69. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  70. package/dist/server/prebuilt-runtime.d.ts.map +1 -1
  71. package/dist/server/request-context.d.ts +6 -5
  72. package/dist/server/request-context.d.ts.map +1 -1
  73. package/dist/server/route-element-builder.d.ts.map +1 -1
  74. package/dist/server/sitemap-generator.d.ts +2 -1
  75. package/dist/server/sitemap-generator.d.ts.map +1 -1
  76. package/dist/server/slot-resolver.d.ts +1 -1
  77. package/dist/server/slot-resolver.d.ts.map +1 -1
  78. package/dist/server/ssr-bridge-types.d.ts +5 -3
  79. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  80. package/dist/server/types.d.ts +2 -1
  81. package/dist/server/types.d.ts.map +1 -1
  82. package/dist/shared/als-slots.d.ts +3 -2
  83. package/dist/shared/als-slots.d.ts.map +1 -1
  84. package/dist/shared/param-value.d.ts +25 -1
  85. package/dist/shared/param-value.d.ts.map +1 -1
  86. package/dist/shared/payload-root.d.ts +2 -1
  87. package/dist/shared/payload-root.d.ts.map +1 -1
  88. package/dist/shared/slot-params.d.ts +4 -3
  89. package/dist/shared/slot-params.d.ts.map +1 -1
  90. package/docs/api/34-api-config.mdx +9 -8
  91. package/docs/more/04b-mdx.mdx +38 -18
  92. package/package.json +7 -3
  93. package/src/client/params-context.ts +2 -2
  94. package/src/client/ssr-data.ts +2 -1
  95. package/src/client/state.ts +3 -2
  96. package/src/client/use-segment-params.ts +6 -5
  97. package/src/config-types.ts +27 -7
  98. package/src/plugins/mdx.ts +69 -35
  99. package/src/plugins/server-bundle.ts +2 -1
  100. package/src/routing/segment-keys.ts +5 -2
  101. package/src/server/action-handler.ts +106 -8
  102. package/src/server/als-registry.ts +10 -5
  103. package/src/server/chain-url-parts.ts +2 -1
  104. package/src/server/index.ts +4 -0
  105. package/src/server/metadata-collector.ts +114 -40
  106. package/src/server/param-coercion.ts +7 -7
  107. package/src/server/pipeline-phases.ts +3 -1
  108. package/src/server/pipeline.ts +3 -2
  109. package/src/server/prebuilt/cache-key.ts +2 -1
  110. package/src/server/prebuilt/payload-source.ts +2 -1
  111. package/src/server/prebuilt/synthetic-store.ts +2 -1
  112. package/src/server/prebuilt-builder.ts +6 -5
  113. package/src/server/prebuilt-runtime.ts +5 -3
  114. package/src/server/request-context.ts +6 -8
  115. package/src/server/route-element-builder.ts +18 -40
  116. package/src/server/sitemap-generator.ts +7 -9
  117. package/src/server/slot-resolver.ts +2 -1
  118. package/src/server/ssr-bridge-types.ts +6 -3
  119. package/src/server/types.ts +2 -1
  120. package/src/shared/als-slots.ts +3 -1
  121. package/src/shared/param-value.ts +44 -7
  122. package/src/shared/payload-root.ts +2 -1
  123. package/src/shared/slot-params.ts +11 -12
@@ -14,6 +14,7 @@
14
14
  * and design/11-platform.md §"AsyncLocalStorage".
15
15
  */
16
16
 
17
+ import type { CoercedParams } from '../shared/param-value.js';
17
18
  import { requestContextAls, type RequestContextStore } from './als-registry.js';
18
19
  import { _setGetSearchParamsFn, _setGetSegmentParamsFn } from '../shared/als-slots.js';
19
20
  import { consumeSeededCookies } from './cookie-context.js';
@@ -101,7 +102,7 @@ _setGetSegmentParamsFn(getSegmentParams);
101
102
  *
102
103
  * See design/41-global-params.md §Function Signatures
103
104
  */
104
- export function getSegmentParams(segmentPath?: string): Record<string, string | string[]> {
105
+ export function getSegmentParams(segmentPath?: string): CoercedParams {
105
106
  const store = requestContextAls.getStore();
106
107
  if (!store) {
107
108
  throw new Error(
@@ -152,7 +153,7 @@ export function getSegmentParams(segmentPath?: string): Record<string, string |
152
153
  *
153
154
  * @internal — framework use only
154
155
  */
155
- export function setSegmentParams(params: Record<string, string | string[]>): void {
156
+ export function setSegmentParams(params: CoercedParams): void {
156
157
  const store = requestContextAls.getStore();
157
158
  if (!store) {
158
159
  throw new Error('[timber] setSegmentParams() called outside of a request context.');
@@ -168,10 +169,7 @@ export function setSegmentParams(params: Record<string, string | string[]>): voi
168
169
  * @param params — The slot's coerced segment params
169
170
  * @internal — framework use only
170
171
  */
171
- export function setSlotParams(
172
- segmentPath: string,
173
- params: Record<string, string | string[]>
174
- ): void {
172
+ export function setSlotParams(segmentPath: string, params: CoercedParams): void {
175
173
  const store = requestContextAls.getStore();
176
174
  if (!store) return; // non-throwing — optional diagnostic
177
175
  if (!store.slotParams) store.slotParams = Object.create(null) as SlotParamsRecord;
@@ -203,7 +201,7 @@ export function setSlotParams(
203
201
  */
204
202
  export function runWithSlotSegmentParams<T>(
205
203
  slotSegmentPath: string | undefined,
206
- slotParams: Record<string, string | string[]>,
204
+ slotParams: CoercedParams,
207
205
  fn: () => T
208
206
  ): T {
209
207
  const store = requestContextAls.getStore();
@@ -230,7 +228,7 @@ export function runWithSlotSegmentParams<T>(
230
228
  *
231
229
  * @internal — framework use only. See TIM-1285.
232
230
  */
233
- export function getSegmentParamsForClient(): Record<string, string | string[]> {
231
+ export function getSegmentParamsForClient(): CoercedParams {
234
232
  return requestContextAls.getStore()?.segmentParams ?? {};
235
233
  }
236
234
 
@@ -220,34 +220,6 @@ function rejectLegacyGenerateMetadata(mod: Record<string, unknown>, filePath: st
220
220
  }
221
221
  }
222
222
 
223
- /**
224
- * Extract and resolve metadata from a module (layout or page).
225
- * Handles both static metadata objects and async metadata functions.
226
- * Returns the resolved Metadata, or null if none exported.
227
- *
228
- * Metadata functions no longer receive { params } — they access params
229
- * via getSegmentParams() from ALS, same as page/layout components.
230
- */
231
- async function extractMetadata(
232
- mod: Record<string, unknown>,
233
- segment: ManifestSegmentNode
234
- ): Promise<Metadata | null> {
235
- if (typeof mod.metadata === 'function') {
236
- type MetadataFn = () => Promise<Metadata>;
237
- return (
238
- (await withSpan(
239
- 'timber.metadata',
240
- { 'timber.segment': segment.segmentName ?? segment.urlPath },
241
- () => (mod.metadata as MetadataFn)()
242
- )) ?? null
243
- );
244
- }
245
- if (mod.metadata) {
246
- return mod.metadata as Metadata;
247
- }
248
- return null;
249
- }
250
-
251
223
  /**
252
224
  * Extract `deferSuspenseFor` from a module and return the maximum
253
225
  * of the current value and the module's value.
@@ -446,18 +418,24 @@ export async function buildRouteElement(
446
418
  element = h(TracedPage, {});
447
419
  }
448
420
 
449
- // Wrap page in MetadataHead — a server component that reads all
450
- // metadata entries collected by MetadataCollector components above it,
451
- // merges them, and renders Float-hoisted head elements as siblings of
452
- // the page. Placed at the page level so metadata is always re-rendered
453
- // on SPA navigation (never skipped by segment tree diffing).
454
- // See TIM-1363, design/16-metadata.md.
455
- element = h(MetadataHead, {
456
- segments,
457
- requestUrl: req.url,
458
- metadataRouteHashes,
459
- children: element,
460
- });
421
+ // Place MetadataHead as a SIBLING of the page, not a wrapper. React
422
+ // renders siblings concurrently — the page starts immediately while
423
+ // MetadataHead awaits metadata promises. Float hoists the head
424
+ // elements to <head> regardless of tree position. Both resolve before
425
+ // onShellReady (outside Suspense), but they overlap.
426
+ // Placed at the page level so metadata is always re-rendered on SPA
427
+ // navigation (never skipped by segment tree diffing).
428
+ // See TIM-1363, TIM-1367, design/16-metadata.md.
429
+ element = h(
430
+ Fragment,
431
+ null,
432
+ h(MetadataHead, {
433
+ segments,
434
+ requestUrl: req.url,
435
+ metadataRouteHashes,
436
+ }),
437
+ element
438
+ );
461
439
 
462
440
  // Build a lookup of layout components by segment for O(1) access.
463
441
  const layoutBySegment = new Map(
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { serializeSitemap, serializeSitemapIndex } from './pipeline-metadata.js';
18
18
  import type { SegmentNode } from '../routing/types.js';
19
+ import type { CoercedParams } from '../shared/param-value.js';
19
20
 
20
21
  // ─── Types ───────────────────────────────────────────────────────────────────
21
22
 
@@ -229,26 +230,23 @@ export async function resolveSitemapUrls(
229
230
  *
230
231
  * @returns The resolved path, or null if params are missing
231
232
  */
232
- export function resolvePathWithParams(
233
- urlPath: string,
234
- params: Record<string, string | string[]>
235
- ): string | null {
233
+ export function resolvePathWithParams(urlPath: string, params: CoercedParams): string | null {
236
234
  let resolved = urlPath;
237
235
 
238
236
  // Optional catch-all: [[...param]]
239
237
  resolved = resolved.replace(/\[\[\.\.\.([^\]]+)\]\]/g, (_, name) => {
240
238
  const value = params[name];
241
239
  if (value === undefined || value === '') return '';
242
- if (Array.isArray(value)) return value.join('/');
243
- return value;
240
+ if (Array.isArray(value)) return value.map(String).join('/');
241
+ return String(value);
244
242
  });
245
243
 
246
244
  // Catch-all: [...param]
247
245
  resolved = resolved.replace(/\[\.\.\.([^\]]+)\]/g, (_, name) => {
248
246
  const value = params[name];
249
247
  if (value === undefined) return '';
250
- if (Array.isArray(value)) return value.join('/');
251
- return value;
248
+ if (Array.isArray(value)) return value.map(String).join('/');
249
+ return String(value);
252
250
  });
253
251
 
254
252
  // Single dynamic: [param] (possibly with affixes like img-[id].png)
@@ -260,7 +258,7 @@ export function resolvePathWithParams(
260
258
  missingRequired = true;
261
259
  return match; // keep original to avoid malformed URL
262
260
  }
263
- const v = Array.isArray(value) ? (value[0] ?? '') : value;
261
+ const v = Array.isArray(value) ? String(value[0] ?? '') : String(value);
264
262
  return prefix + v + suffix;
265
263
  });
266
264
 
@@ -28,6 +28,7 @@ import { logRenderError } from './logger.js';
28
28
  import type { ManifestSegmentNode } from './route-matcher.js';
29
29
  import { setSlotParams, runWithSlotSegmentParams } from './request-context.js';
30
30
  import { coerceSlotParams } from './param-coercion.js';
31
+ import type { CoercedParams } from '../shared/param-value.js';
31
32
  import { matchUrlParts } from './tree-match.js';
32
33
  import { extractUrlParts, consumedPartsThroughSegment } from './chain-url-parts.js';
33
34
  import { findInterceptingMatch } from './slot-interception.js';
@@ -391,7 +392,7 @@ export interface SlotMatchResult {
391
392
  page: NonNullable<ManifestSegmentNode['page']>;
392
393
  /** The full chain of slot nodes traversed (slot root → … → leaf with page). */
393
394
  chain: ManifestSegmentNode[];
394
- /** Params collected from the slot's segment chain (may differ from main route params). */
395
+ /** Params collected from the slot's segment chain (pre-coercion, may differ from main route params). */
395
396
  slotParams: Record<string, string | string[]>;
396
397
  }
397
398
 
@@ -29,6 +29,9 @@
29
29
  * and `CLAUDE.md` §"RSC and SSR Are Separate Vite Environments".
30
30
  */
31
31
 
32
+ import type { CoercedParams } from '../shared/param-value.js';
33
+ import type { SlotParamsRecord } from '../shared/slot-params.js';
34
+
32
35
  /**
33
36
  * Navigation context passed from the RSC environment to SSR.
34
37
  *
@@ -38,8 +41,8 @@
38
41
  export interface NavContext {
39
42
  /** The requested pathname */
40
43
  pathname: string;
41
- /** Extracted route segment params (catch-all segments produce string[]) */
42
- segmentParams: Record<string, string | string[]>;
44
+ /** Extracted route segment params. Post-coercion, values may be non-string (TIM-1347). */
45
+ segmentParams: CoercedParams;
43
46
  /** Search params from the URL */
44
47
  searchParams: Record<string, string>;
45
48
  /**
@@ -47,7 +50,7 @@ export interface NavContext {
47
50
  * rendered no slot with params of its own. Feeds `useSegmentParams(path)`
48
51
  * during SSR and, via `__timber_slot_params`, after hydration (TIM-1285).
49
52
  */
50
- slotParams?: Record<string, Record<string, string | string[]>>;
53
+ slotParams?: SlotParamsRecord;
51
54
  /** The committed HTTP status code */
52
55
  statusCode: number;
53
56
  /** Response headers from middleware/proxy */
@@ -1,5 +1,6 @@
1
1
  // Server-side type definitions
2
2
 
3
+ import type { CoercedParams } from '../shared/param-value.js';
3
4
  import type { EarlyHint } from './early-hints.js';
4
5
 
5
6
  /**
@@ -22,7 +23,7 @@ export interface MiddlewareContext {
22
23
  req: Request;
23
24
  requestHeaders: Headers;
24
25
  headers: Headers;
25
- segmentParams: Record<string, string | string[]>;
26
+ segmentParams: CoercedParams;
26
27
  /** Declare early hints for critical resources. Appends Link headers. */
27
28
  earlyHints: (hints: EarlyHint[]) => void;
28
29
  }
@@ -31,6 +31,8 @@
31
31
  * design/41-global-params.md.
32
32
  */
33
33
 
34
+ import type { CoercedParams } from './param-value.js';
35
+
34
36
  interface AlsSlot<T> {
35
37
  /** Register the server-side getter. Called once at module-init time. */
36
38
  set(fn: () => T): void;
@@ -56,7 +58,7 @@ const searchParamsSlot = createAlsSlot<URLSearchParams>(
56
58
  'Use searchParams.useQueryStates() on the client.'
57
59
  );
58
60
 
59
- const segmentParamsSlot = createAlsSlot<Record<string, string | string[]>>(
61
+ const segmentParamsSlot = createAlsSlot<CoercedParams>(
60
62
  '[timber] segmentParams.get() is only available on the server. ' +
61
63
  'Use segmentParams.useSegmentParams() on the client.'
62
64
  );
@@ -62,6 +62,43 @@
62
62
  * See design/41-global-params.md §"Transport".
63
63
  */
64
64
 
65
+ // ─── Post-coercion param types ──────────────────────────────────────────
66
+
67
+ /**
68
+ * A single segment param value after codec coercion and normalization.
69
+ *
70
+ * Before coercion, every param is `string | string[]` (the raw URL part).
71
+ * After `app/schema.ts` codecs run, a param can be any value in the
72
+ * `normalizeParamValue` domain below. This type replaces the previous
73
+ * `string | string[]` declaration, which was a lie whenever a codec
74
+ * produced a non-string value (TIM-1347).
75
+ *
76
+ * The typed accessor `getSegmentParams(SEGMENT_PATH)` resolves through
77
+ * the schema and returns the exact type the codec declares. This type
78
+ * covers the untyped/no-argument form and internal plumbing.
79
+ */
80
+ export type CoercedParamValue =
81
+ | string
82
+ | number
83
+ | boolean
84
+ | bigint
85
+ | null
86
+ | undefined
87
+ | Date
88
+ | Map<CoercedParamValue, CoercedParamValue>
89
+ | Set<CoercedParamValue>
90
+ | CoercedParamValue[]
91
+ | { [key: string]: CoercedParamValue };
92
+
93
+ /**
94
+ * A record of coerced segment params, keyed by param name.
95
+ *
96
+ * This is the post-coercion type for `segmentParams` throughout the
97
+ * framework. Before coercion (route matcher output, `rawSegmentParams`),
98
+ * the type is `Record<string, string | string[]>`.
99
+ */
100
+ export type CoercedParams = Record<string, CoercedParamValue>;
101
+
65
102
  /** Types the canonical form rebuilds rather than rejects. */
66
103
  const REBUILT = ['Date', 'Map', 'Set'] as const;
67
104
 
@@ -88,16 +125,16 @@ function describe(value: object): string {
88
125
  * is rejected. That is one rule for two things — a shared reference and a cycle
89
126
  * are both "this object again" — and it is what keeps the result a tree.
90
127
  */
91
- export function normalizeParamValue(value: unknown, path = ''): unknown {
128
+ export function normalizeParamValue(value: unknown, path = ''): CoercedParamValue {
92
129
  return normalize(value, path, new Set());
93
130
  }
94
131
 
95
- function normalize(value: unknown, path: string, seen: Set<object>): unknown {
132
+ function normalize(value: unknown, path: string, seen: Set<object>): CoercedParamValue {
96
133
  if (value === null) return null;
97
134
 
98
135
  const kind = typeof value;
99
136
  if (kind === 'string' || kind === 'number' || kind === 'boolean' || kind === 'bigint') {
100
- return value;
137
+ return value as string | number | boolean | bigint;
101
138
  }
102
139
  if (kind === 'undefined') return undefined;
103
140
  if (kind === 'function') {
@@ -124,7 +161,7 @@ function normalize(value: unknown, path: string, seen: Set<object>): unknown {
124
161
  // Read by index, so holes become `undefined` — on both sides, which is
125
162
  // the point. A subclass and any own properties are left behind for the
126
163
  // same reason: the clone is a plain, dense array either way.
127
- const out: unknown[] = [];
164
+ const out: CoercedParamValue[] = [];
128
165
  for (let index = 0; index < object.length; index++) {
129
166
  out.push(normalize((object as unknown[])[index], `${path}[${index}]`, seen));
130
167
  }
@@ -138,7 +175,7 @@ function normalize(value: unknown, path: string, seen: Set<object>): unknown {
138
175
  }
139
176
 
140
177
  if (object instanceof Map) {
141
- const out = new Map<unknown, unknown>();
178
+ const out = new Map<CoercedParamValue, CoercedParamValue>();
142
179
  for (const [key, entry] of object) {
143
180
  out.set(
144
181
  normalize(key, `${path}<key>`, seen),
@@ -149,7 +186,7 @@ function normalize(value: unknown, path: string, seen: Set<object>): unknown {
149
186
  }
150
187
 
151
188
  if (object instanceof Set) {
152
- const out = new Set<unknown>();
189
+ const out = new Set<CoercedParamValue>();
153
190
  let index = 0;
154
191
  for (const entry of object) out.add(normalize(entry, `${path}[${index++}]`, seen));
155
192
  return out;
@@ -171,7 +208,7 @@ function normalize(value: unknown, path: string, seen: Set<object>): unknown {
171
208
  // so dropping it here keeps the two sides identical. `__proto__` is skipped
172
209
  // rather than copied: it has a language-level setter that would change the
173
210
  // prototype chain of the copy (TIM-655, TIM-855, TIM-873).
174
- const out: Record<string, unknown> = Object.create(null);
211
+ const out: Record<string, CoercedParamValue> = Object.create(null);
175
212
  for (const key of Object.keys(object)) {
176
213
  if (key === '__proto__') continue;
177
214
  out[key] = normalize(
@@ -32,12 +32,13 @@
32
32
  * helpers here — producer and consumer do not each describe the payload.
33
33
  */
34
34
 
35
+ import type { CoercedParams } from './param-value.js';
35
36
  import type { SlotParamsRecord } from './slot-params.js';
36
37
 
37
38
  /** The params published beside a tree. */
38
39
  export interface PublishedParams {
39
40
  /** The main route's coerced params. */
40
- params: Record<string, string | string[]>;
41
+ params: CoercedParams;
41
42
  /** Per-slot params, or null when the route rendered no slots with params. */
42
43
  slotParams: SlotParamsRecord | null;
43
44
  }
@@ -18,8 +18,10 @@
18
18
  * See design/41-global-params.md §"Params in a parallel slot".
19
19
  */
20
20
 
21
+ import type { CoercedParams } from './param-value.js';
22
+
21
23
  /** Slot tree path → that slot's own coerced params. */
22
- export type SlotParamsRecord = Record<string, Record<string, string | string[]>>;
24
+ export type SlotParamsRecord = Record<string, CoercedParams>;
23
25
 
24
26
  /**
25
27
  * The params a slot sees: the main route's as a base, the slot's own on top.
@@ -37,9 +39,9 @@ export type SlotParamsRecord = Record<string, Record<string, string | string[]>>
37
39
  * by merging. See design/13-security.md #36c.
38
40
  */
39
41
  export function mergeSlotParams(
40
- mainParams: Record<string, string | string[]>,
41
- slotParams: Record<string, string | string[]>
42
- ): Record<string, string | string[]> {
42
+ mainParams: CoercedParams,
43
+ slotParams: CoercedParams
44
+ ): CoercedParams {
43
45
  return Object.assign(Object.create(null), mainParams, slotParams);
44
46
  }
45
47
 
@@ -56,10 +58,10 @@ export function mergeSlotParams(
56
58
  * resolve to an inherited member of `Object.prototype`.
57
59
  */
58
60
  export function resolveSegmentParams(
59
- mainParams: Record<string, string | string[]>,
61
+ mainParams: CoercedParams,
60
62
  slotParams: SlotParamsRecord | null | undefined,
61
63
  segmentPath: string | undefined
62
- ): Record<string, string | string[]> {
64
+ ): CoercedParams {
63
65
  if (!segmentPath || !slotParams || !Object.hasOwn(slotParams, segmentPath)) return mainParams;
64
66
  return cachedMerge(mainParams, slotParams, segmentPath);
65
67
  }
@@ -83,16 +85,13 @@ export function resolveSegmentParams(
83
85
  * A `WeakMap` chain rather than a `Map`, so a superseded navigation's records
84
86
  * and their merges become collectable as soon as nothing else holds them.
85
87
  */
86
- const mergeCache = new WeakMap<
87
- object,
88
- WeakMap<object, Map<string, Record<string, string | string[]>>>
89
- >();
88
+ const mergeCache = new WeakMap<object, WeakMap<object, Map<string, CoercedParams>>>();
90
89
 
91
90
  function cachedMerge(
92
- mainParams: Record<string, string | string[]>,
91
+ mainParams: CoercedParams,
93
92
  slotParams: SlotParamsRecord,
94
93
  segmentPath: string
95
- ): Record<string, string | string[]> {
94
+ ): CoercedParams {
96
95
  let bySlotMap = mergeCache.get(mainParams);
97
96
  if (!bySlotMap) {
98
97
  bySlotMap = new WeakMap();