@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
@@ -21,6 +21,7 @@ import {
21
21
  } from '../rsc-runtime/rsc.js';
22
22
 
23
23
  import { validateCsrf, type CsrfConfig } from './csrf.js';
24
+ import { DenySignal, RedirectSignal } from './primitives.js';
24
25
  import { executeAction, type RevalidateRenderer } from './actions.js';
25
26
  import { runWithRequestContext, setMutableCookieContext } from './request-context.js';
26
27
  import { getSetCookieHeaders, getCookiesForSsr } from './cookie-context.js';
@@ -36,6 +37,7 @@ import {
36
37
  import type { FormFlashData } from './form-flash.js';
37
38
  import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
38
39
  import { logActionError } from './logger.js';
40
+ import { withTimeout, RenderTimeoutError } from './render-timeout.js';
39
41
  import { RSC_CONTENT_TYPE } from '../shared/rsc-media-type.js';
40
42
 
41
43
  // ─── Types ────────────────────────────────────────────────────────────────
@@ -287,25 +289,107 @@ async function handleRscAction(
287
289
  'Content-Type': RSC_CONTENT_TYPE,
288
290
  };
289
291
 
290
- let payload: unknown;
291
292
  if (result.revalidation) {
292
293
  // Wrapper object — Next.js-style pattern: action result + element tree
293
294
  // serialized together so React Flight handles both in one stream.
294
- payload = {
295
+ const revalidationPayload = {
295
296
  _action: result.actionResult,
296
297
  // The payload root, not a bare tree: it carries the revalidated
297
298
  // route's params too, and the client publishes them (TIM-1297).
298
299
  _tree: result.revalidation.payload,
299
300
  };
301
+
302
+ // TIM-1366: access.ts deny signals fire during serialization (inside
303
+ // AccessGate during renderToReadableStream), not during buildRouteElement.
304
+ // Buffer the stream and track errors so we can drop the revalidation
305
+ // cleanly instead of shipping a broken flight stream. Access runs
306
+ // exactly once — during the real render — preserving TIM-1363.
307
+ let serializationSignal:
308
+ | { kind: 'deny'; status: number }
309
+ | { kind: 'redirect'; location: string; status: number }
310
+ | { kind: 'error' }
311
+ | undefined;
312
+ const rscStream = renderToReadableStream(revalidationPayload, {
313
+ signal: req.signal,
314
+ onError(error: unknown) {
315
+ // ??= keeps the first signal (shallowest AccessGate in top-down render).
316
+ if (error instanceof DenySignal) {
317
+ serializationSignal ??= { kind: 'deny', status: error.status };
318
+ } else if (error instanceof RedirectSignal) {
319
+ serializationSignal ??= {
320
+ kind: 'redirect',
321
+ location: error.location,
322
+ status: error.status,
323
+ };
324
+ } else {
325
+ console.error('[timber] revalidation serialization error:', error);
326
+ serializationSignal ??= { kind: 'error' };
327
+ }
328
+ },
329
+ });
330
+
331
+ // Drain the stream to detect any signals before committing the response.
332
+ // Timeout-guarded: the element tree may contain async server components
333
+ // with stalled fetches. 30s matches the default renderTimeoutMs.
334
+ const reader = rscStream.getReader();
335
+ const chunks: Uint8Array[] = [];
336
+ const drainTimeoutMs = 30_000;
337
+ try {
338
+ for (;;) {
339
+ const { done, value } = await withTimeout(
340
+ reader.read(),
341
+ drainTimeoutMs,
342
+ 'revalidation stream drain timed out'
343
+ );
344
+ if (done) break;
345
+ chunks.push(value);
346
+ if (serializationSignal) {
347
+ reader.cancel().catch(() => {});
348
+ break;
349
+ }
350
+ }
351
+ } catch (drainError) {
352
+ reader.cancel().catch(() => {});
353
+ if (drainError instanceof RenderTimeoutError) {
354
+ console.error('[timber] revalidatePath dropped — stream drain timed out');
355
+ const fallbackStream = renderToReadableStream(result.actionResult);
356
+ return new Response(fallbackStream, { status: 200, headers });
357
+ }
358
+ throw drainError;
359
+ }
360
+
361
+ if (serializationSignal) {
362
+ if (serializationSignal.kind === 'redirect') {
363
+ const redirectPayload = {
364
+ _redirect: serializationSignal.location,
365
+ _status: serializationSignal.status,
366
+ };
367
+ const redirectStream = renderToReadableStream(redirectPayload);
368
+ return new Response(redirectStream, {
369
+ status: 200,
370
+ headers: {
371
+ 'Content-Type': RSC_CONTENT_TYPE,
372
+ 'X-Timber-Redirect': serializationSignal.location,
373
+ },
374
+ });
375
+ }
376
+ // Deny or render error: drop the revalidation tree, ship the action result alone.
377
+ if (serializationSignal.kind === 'deny') {
378
+ console.error(
379
+ `[timber] revalidatePath dropped — access denied during render (status ${serializationSignal.status})`
380
+ );
381
+ }
382
+ const fallbackStream = renderToReadableStream(result.actionResult);
383
+ return new Response(fallbackStream, { status: 200, headers });
384
+ }
385
+
386
+ // Clean serialization — return the buffered bytes with revalidation header.
300
387
  headers['X-Timber-Revalidation'] = '1';
301
- // Metadata (<title>/<meta>/<link>) now rides the revalidation element
302
- // tree as React elements — React 19 Float manages them in document.head
303
- // during client rendering. No X-Timber-Head header needed. See TIM-1151.
304
- } else {
305
- payload = result.actionResult;
388
+ const buffered = concatUint8Arrays(chunks);
389
+ return new Response(buffered.buffer as ArrayBuffer, { status: 200, headers });
306
390
  }
307
391
 
308
- const rscStream = renderToReadableStream(payload);
392
+ const rscStream = renderToReadableStream(result.actionResult);
309
393
 
310
394
  return new Response(rscStream, {
311
395
  status: 200,
@@ -313,6 +397,20 @@ async function handleRscAction(
313
397
  });
314
398
  }
315
399
 
400
+ // ─── Helpers ─────────────────────────────────────────────────────────────
401
+
402
+ function concatUint8Arrays(chunks: Uint8Array[]): Uint8Array {
403
+ let total = 0;
404
+ for (const c of chunks) total += c.byteLength;
405
+ const out = new Uint8Array(total);
406
+ let offset = 0;
407
+ for (const c of chunks) {
408
+ out.set(c, offset);
409
+ offset += c.byteLength;
410
+ }
411
+ return out;
412
+ }
413
+
316
414
  // ─── No-JS Form Action ───────────────────────────────────────────────────
317
415
 
318
416
  /**
@@ -32,6 +32,7 @@
32
32
  */
33
33
 
34
34
  import '#server-only-guard';
35
+ import type { CoercedParams } from '../shared/param-value.js';
35
36
  import type { SlotParamsRecord } from '../shared/slot-params.js';
36
37
  import { AsyncLocalStorage } from 'node:async_hooks';
37
38
  import type { DebugComponentEntry } from './rsc-entry/helpers.js';
@@ -94,7 +95,7 @@ export interface RequestContextStore {
94
95
  *
95
96
  * See design/07-routing.md §"params.ts — Convention File for Typed Params"
96
97
  */
97
- segmentParams?: Record<string, string | string[]>;
98
+ segmentParams?: CoercedParams;
98
99
  /**
99
100
  * The matched segment path (e.g. '/(browse)/[artistSlug]/[year]').
100
101
  * Includes route groups and parallel slots — uniquely identifies the
@@ -152,11 +153,15 @@ export interface RequestContextStore {
152
153
  /**
153
154
  * Metadata entries collected during render by MetadataCollector.
154
155
  * Populated per-segment in tree order (root→page) during
155
- * renderToReadableStream. MetadataHead reads all entries,
156
- * merges them, and renders Float-hoisted head elements.
157
- * See TIM-1363, design/16-metadata.md.
156
+ * renderToReadableStream. Entries may contain promises for dynamic
157
+ * metadata (resolved concurrently with page rendering, TIM-1367).
158
+ * MetadataHead awaits all entries before merging.
159
+ * See TIM-1363, TIM-1367, design/16-metadata.md.
158
160
  */
159
- _metadataEntries?: import('./metadata.js').SegmentMetadataEntry[];
161
+ _metadataEntries?: Array<{
162
+ metadata: import('./types.js').Metadata | Promise<import('./types.js').Metadata | null>;
163
+ isPage: boolean;
164
+ }>;
160
165
  }
161
166
 
162
167
  /** A single outgoing cookie entry in the cookie jar. */
@@ -22,6 +22,7 @@
22
22
 
23
23
  import type { ManifestSegmentNode } from './route-matcher.js';
24
24
  import type { RouteMatch } from './pipeline.js';
25
+ import type { CoercedParams } from '../shared/param-value.js';
25
26
  import { effectiveUrlSegment } from '../routing/segment-classify.js';
26
27
  import { computeInterceptedBase } from '../routing/interception.js';
27
28
 
@@ -51,7 +52,7 @@ import { computeInterceptedBase } from '../routing/interception.js';
51
52
  */
52
53
  export function extractUrlParts(
53
54
  segments: ManifestSegmentNode[],
54
- mainParams: Record<string, string | string[]>
55
+ mainParams: CoercedParams
55
56
  ): string[] {
56
57
  const parts: string[] = [];
57
58
  for (const node of segments) {
@@ -64,4 +64,8 @@ export { revalidatePath, revalidateTag } from './actions';
64
64
  // Design doc: design/17-logging.md §"trace_id is Always Set"
65
65
  export { getTraceId, getSpanId, withSpan, addSpanEvent } from './tracing';
66
66
 
67
+ // Coerced param types — the post-coercion domain for segment params.
68
+ // TIM-1347: these replaced the previous `Record<string, string | string[]>` lie.
69
+ export type { CoercedParamValue, CoercedParams } from '../shared/param-value';
70
+
67
71
  // Segment params types — re-exported for convenience.
@@ -6,12 +6,21 @@
6
6
  * the page level, merging all collected entries and rendering Float-
7
7
  * hoisted head elements.
8
8
  *
9
- * This structural approach replaces the eager pre-render metadata
10
- * resolution: a denied segment's AccessGate prevents MetadataCollector
11
- * from rendering, so metadata() never executes for denied segments.
12
- * No `firstDeniedIndex` bookkeeping needed.
9
+ * MetadataCollector is synchronous: it starts dynamic metadata() calls
10
+ * without awaiting and pushes the promise into the entries array, then
11
+ * returns children immediately. This lets React descend into the page
12
+ * subtree while metadata I/O runs concurrently (TIM-1367). A sibling
13
+ * MetadataSettle component awaits the promise so errors surface at the
14
+ * collector's tree position (caught by that segment's error boundary).
13
15
  *
14
- * See TIM-1363, design/16-metadata.md.
16
+ * MetadataHead is async: it awaits all entry promises before merging.
17
+ * Ordering is guaranteed by tree position — ancestor collectors push
18
+ * before MetadataHead renders.
19
+ *
20
+ * AccessGate denial prevents MetadataCollector from rendering, so
21
+ * metadata() never starts for denied segments (TIM-1027).
22
+ *
23
+ * See TIM-1363, TIM-1367, design/16-metadata.md.
15
24
  */
16
25
 
17
26
  import { createElement, Fragment } from 'react';
@@ -27,11 +36,41 @@ import { collectMetadataRouteHeadElements } from './metadata-routes.js';
27
36
 
28
37
  // ─── Request-scoped metadata entry store ────────────────────────────────
29
38
 
30
- function getMetadataEntries(): SegmentMetadataEntry[] {
39
+ /**
40
+ * Internal entry type that supports deferred (promise) metadata for
41
+ * concurrent resolution (TIM-1367). Dynamic metadata pushes a promise;
42
+ * static metadata pushes the resolved object directly.
43
+ */
44
+ interface DeferredMetadataEntry {
45
+ metadata: Metadata | Promise<Metadata | null>;
46
+ isPage: boolean;
47
+ }
48
+
49
+ function getDeferredMetadataEntries(): DeferredMetadataEntry[] {
31
50
  const store = requestContextAls.getStore();
32
51
  if (!store) return [];
33
52
  store._metadataEntries ??= [];
34
- return store._metadataEntries;
53
+ return store._metadataEntries as DeferredMetadataEntry[];
54
+ }
55
+
56
+ // ─── MetadataSettle ─────────────────────────────────────────────────────
57
+
58
+ interface MetadataSettleProps {
59
+ promise: Promise<Metadata | null>;
60
+ }
61
+
62
+ /**
63
+ * Tiny async server component rendered as a sibling of the collector's
64
+ * children. Its sole job is error ownership: awaiting the metadata
65
+ * promise so that rejections surface at THIS tree position (caught by
66
+ * the owning segment's error boundary, not MetadataHead's).
67
+ *
68
+ * Shell settlement (onSettled) is handled by the collector attaching
69
+ * directly to the promise — unconditional on render progress.
70
+ */
71
+ async function MetadataSettle(props: MetadataSettleProps): Promise<ReactNode> {
72
+ await props.promise;
73
+ return null;
35
74
  }
36
75
 
37
76
  // ─── MetadataCollector ──────────────────────────────────────────────────
@@ -45,36 +84,47 @@ export interface MetadataCollectorProps {
45
84
  }
46
85
 
47
86
  /**
48
- * Async server component that collects one segment's metadata during
49
- * render. Placed between AccessGate and the layout/page. When
87
+ * Synchronous server component that collects one segment's metadata
88
+ * during render. Placed between AccessGate and the layout/page. When
50
89
  * AccessGate denies, this component never renders, so dynamic
51
- * metadata() never executes for denied segments (TIM-1027).
90
+ * metadata() never starts for denied segments (TIM-1027).
52
91
  *
53
92
  * Static metadata objects are pushed synchronously (no side effects).
54
- * Dynamic metadata functions are awaited with OTEL tracing.
93
+ * Dynamic metadata functions are started (not awaited) and pushed as
94
+ * promises — React descends into children immediately while the I/O
95
+ * runs concurrently (TIM-1367). Shell settlement (onSettled) is
96
+ * attached directly to the promise so it fires unconditionally. A
97
+ * sibling MetadataSettle component awaits the promise for error
98
+ * ownership only.
55
99
  */
56
- export async function MetadataCollector(props: MetadataCollectorProps): Promise<ReactNode> {
100
+ export function MetadataCollector(props: MetadataCollectorProps): ReactNode {
57
101
  const { metadataExport, segmentName, isPage, onSettled, children } = props;
58
102
 
59
- try {
60
- let metadata: Metadata | null = null;
61
- if (typeof metadataExport === 'function') {
62
- metadata =
63
- (await withSpan('timber.metadata', { 'timber.segment': segmentName }, () =>
64
- (metadataExport as () => Promise<Metadata>)()
65
- )) ?? null;
66
- } else {
67
- metadata = metadataExport;
68
- }
103
+ if (typeof metadataExport === 'function') {
104
+ const promise = withSpan('timber.metadata', { 'timber.segment': segmentName }, () =>
105
+ (metadataExport as () => Promise<Metadata>)()
106
+ ).then((v) => v ?? null);
107
+ // Suppress unhandled rejection — MetadataSettle and MetadataHead
108
+ // both attach handlers, but a fast-rejecting promise can fire
109
+ // Node's unhandledRejection before they do.
110
+ promise.catch(() => {});
111
+ // Shell settlement: unconditional on render progress. If React
112
+ // never reaches MetadataSettle (e.g., parent error boundary fires
113
+ // first), the HTTP status still commits on time.
114
+ if (onSettled)
115
+ promise.then(
116
+ () => onSettled(),
117
+ () => onSettled()
118
+ );
69
119
 
70
- if (metadata) {
71
- getMetadataEntries().push({ metadata, isPage });
72
- }
120
+ getDeferredMetadataEntries().push({ metadata: promise, isPage });
73
121
 
74
- return children;
75
- } finally {
76
- onSettled?.();
122
+ return createElement(Fragment, null, children, createElement(MetadataSettle, { promise }));
77
123
  }
124
+
125
+ getDeferredMetadataEntries().push({ metadata: metadataExport, isPage });
126
+ onSettled?.();
127
+ return children;
78
128
  }
79
129
 
80
130
  // ─── MetadataHead ───────────────────────────────────────────────────────
@@ -83,21 +133,46 @@ export interface MetadataHeadProps {
83
133
  segments: ManifestSegmentNode[];
84
134
  requestUrl: string;
85
135
  metadataRouteHashes?: Record<string, string>;
86
- children: ReactNode;
87
136
  }
88
137
 
89
138
  /**
90
- * Server component at the page level. Reads all metadata entries
91
- * collected by MetadataCollector components above it, merges them,
92
- * auto-links metadata routes, and renders Float-hoisted head elements.
139
+ * Async server component that awaits all deferred metadata promises,
140
+ * merges entries, auto-links metadata routes, and renders Float-
141
+ * hoisted head elements.
142
+ *
143
+ * Rendered as a SIBLING of the page element (not a wrapper) so React
144
+ * processes both concurrently — the page starts rendering immediately
145
+ * while MetadataHead awaits metadata promises. Both resolve before
146
+ * onShellReady (they're outside Suspense), but they overlap. Float
147
+ * hoists the head elements to <head> regardless of tree position.
93
148
  *
94
- * Placed as a sibling of the page element inside a Fragment so the
95
- * Float elements are always at the page segment level (re-rendered
96
- * on every SPA navigation, never skipped by segment tree diffing).
149
+ * Ordering is guaranteed by tree position: every MetadataCollector
150
+ * is an ancestor of the Fragment containing MetadataHead, so its
151
+ * synchronous push into the entries array happens-before MetadataHead
152
+ * executes.
153
+ *
154
+ * See TIM-1367 for the concurrent resolution design.
97
155
  */
98
- export function MetadataHead(props: MetadataHeadProps): ReactNode {
99
- const { segments, requestUrl, metadataRouteHashes, children } = props;
100
- const entries = getMetadataEntries();
156
+ export async function MetadataHead(props: MetadataHeadProps): Promise<ReactNode> {
157
+ const { segments, requestUrl, metadataRouteHashes } = props;
158
+ const deferredEntries = getDeferredMetadataEntries();
159
+
160
+ // Resolve all deferred metadata concurrently. Rejected entries are
161
+ // dropped — their errors are surfaced by MetadataSettle at the
162
+ // owning segment's error boundary, not here.
163
+ const settled = await Promise.allSettled(
164
+ deferredEntries.map((e) =>
165
+ e.metadata instanceof Promise ? e.metadata : Promise.resolve(e.metadata)
166
+ )
167
+ );
168
+ const entries: SegmentMetadataEntry[] = [];
169
+ for (let i = 0; i < settled.length; i++) {
170
+ const s = settled[i];
171
+ if (s.status === 'fulfilled' && s.value) {
172
+ entries.push({ metadata: s.value, isPage: deferredEntries[i].isPage });
173
+ }
174
+ }
175
+
101
176
  const resolved = resolveMetadata(entries);
102
177
  const headElements = renderMetadataToElements(resolved);
103
178
 
@@ -110,6 +185,5 @@ export function MetadataHead(props: MetadataHeadProps): ReactNode {
110
185
  );
111
186
  headElements.push(...autoLinked);
112
187
 
113
- const metadataElement = headElementsToReact(headElements);
114
- return createElement(Fragment, null, metadataElement, children);
188
+ return headElementsToReact(headElements);
115
189
  }
@@ -17,7 +17,7 @@ import type { SegmentType } from '../routing/types.js';
17
17
  import { effectiveUrlSegment } from '../routing/segment-classify.js';
18
18
  import { toBracketKey } from '../params/resolve-schema.js';
19
19
  import type { RouteMatch } from './pipeline.js';
20
- import { normalizeParamValue } from '../shared/param-value.js';
20
+ import { normalizeParamValue, type CoercedParams } from '../shared/param-value.js';
21
21
  import { ParamCoercionError } from './route-element-builder.js';
22
22
  import { isDebug } from './debug.js';
23
23
 
@@ -65,11 +65,11 @@ export function coerceSlotParams(
65
65
  interceptedSegmentName?: string;
66
66
  }>,
67
67
  rawParams: Record<string, string | string[]>
68
- ): Record<string, string | string[]> {
68
+ ): CoercedParams {
69
69
  const globalCodecs = _globalCodecs;
70
70
  if (!globalCodecs) return rawParams;
71
71
 
72
- const result: Record<string, string | string[]> = Object.create(null);
72
+ const result: CoercedParams = Object.create(null);
73
73
  for (const key of Object.keys(rawParams)) {
74
74
  result[key] = rawParams[key];
75
75
  }
@@ -102,10 +102,10 @@ export function coerceSlotParams(
102
102
  }
103
103
  continue;
104
104
  }
105
- result[key] = normalizeParamValue(parsed, key) as string | string[];
105
+ result[key] = normalizeParamValue(parsed, key);
106
106
  }
107
107
 
108
- return result as Record<string, string | string[]>;
108
+ return result;
109
109
  }
110
110
 
111
111
  /**
@@ -134,13 +134,13 @@ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
134
134
  // Unconditionally install a null-prototype target so the invariant
135
135
  // "match.segmentParams is null-prototype" holds from the first line,
136
136
  // regardless of whether any segment has a codec.
137
- const mergeTarget: Record<string, unknown> = Object.create(null);
137
+ const mergeTarget: CoercedParams = Object.create(null);
138
138
  for (const key of Object.keys(match.segmentParams)) {
139
139
  if (key !== '__proto__') {
140
140
  mergeTarget[key] = match.segmentParams[key as keyof typeof match.segmentParams];
141
141
  }
142
142
  }
143
- match.segmentParams = mergeTarget as RouteMatch['segmentParams'];
143
+ match.segmentParams = mergeTarget;
144
144
 
145
145
  // TIM-931/TIM-936: When a global schema is available, use it for coercion
146
146
  // instead of per-segment params.ts files. The global codec map is keyed
@@ -528,7 +528,9 @@ export async function handleRequest(
528
528
  // Snapshot raw params before coercion — slot resolution needs the
529
529
  // original string values to reconstruct URL parts for tree matching.
530
530
  // Coerced params may have been transformed by codecs.
531
- match.rawSegmentParams = { ...match.segmentParams };
531
+ // segmentParams is typed CoercedParams but is still raw strings here
532
+ // (coercion runs below). The snapshot preserves the pre-coercion shape.
533
+ match.rawSegmentParams = { ...match.segmentParams } as Record<string, string | string[]>;
532
534
  try {
533
535
  await coerceSegmentParams(match);
534
536
  } catch (error) {
@@ -15,6 +15,7 @@
15
15
  * and design/17-logging.md §"Production Logging"
16
16
  */
17
17
 
18
+ import type { CoercedParams } from '../shared/param-value.js';
18
19
  import type { ProxyExport } from './proxy.js';
19
20
  import type { MiddlewareFn } from './middleware-runner.js';
20
21
  import { runWithTimingCollector, getServerTimingHeader } from './server-timing.js';
@@ -54,8 +55,8 @@ import { isDebug } from './debug.js';
54
55
  export interface RouteMatch {
55
56
  /** The matched segment chain from root to leaf. */
56
57
  segments: ManifestSegmentNode[];
57
- /** Extracted segment params (catch-all segments produce string[]). */
58
- segmentParams: Record<string, string | string[]>;
58
+ /** Extracted segment params (catch-all segments produce string[]). Post-coercion, values may be non-string (TIM-1347). */
59
+ segmentParams: CoercedParams;
59
60
  /**
60
61
  * Raw segment params before codec coercion. Always string or string[].
61
62
  * Used by slot resolution to reconstruct URL parts — coerced params may
@@ -41,6 +41,7 @@
41
41
  */
42
42
 
43
43
  import { createHash } from 'node:crypto';
44
+ import type { CoercedParams } from '../../shared/param-value.js';
44
45
 
45
46
  class UnkeyableValueError extends Error {}
46
47
 
@@ -260,7 +261,7 @@ export function splitSlotProps(
260
261
  */
261
262
  export function computeComponentCacheKey(
262
263
  props: Record<string, unknown>,
263
- params: Record<string, string | string[]>
264
+ params: CoercedParams
264
265
  ): string | null {
265
266
  let canonical: string;
266
267
  try {
@@ -17,10 +17,11 @@
17
17
  */
18
18
 
19
19
  import { initTagIndex, resetTagIndex } from './overlay.js';
20
+ import type { CoercedParams } from '../../shared/param-value.js';
20
21
 
21
22
  export interface PrebuiltManifestEntry {
22
23
  /** Segment params this entry was captured under. */
23
- params: Record<string, string | string[]>;
24
+ params: CoercedParams;
24
25
  /** Payload path relative to the prebuilt/ directory. */
25
26
  file: string;
26
27
  /** Payload size in bytes (build report / diagnostics). */
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import type { RequestContextStore } from '../als-registry.js';
38
+ import type { CoercedParams } from '../../shared/param-value.js';
38
39
 
39
40
  /**
40
41
  * Thrown when a prerendered component touches per-request context at
@@ -79,7 +80,7 @@ export interface BuildTimeStoreHandle {
79
80
  */
80
81
  export function createBuildTimeStore(
81
82
  componentId: string,
82
- segmentParams: Record<string, string | string[]>
83
+ segmentParams: CoercedParams
83
84
  ): BuildTimeStoreHandle {
84
85
  const violations: BuildTimeContextError[] = [];
85
86
  const handle: BuildTimeStoreHandle = {
@@ -45,6 +45,7 @@
45
45
  import { createElement } from 'react';
46
46
 
47
47
  import { requestContextAls } from './als-registry.js';
48
+ import type { CoercedParams } from '../shared/param-value.js';
48
49
  import { ParamCoercionError } from './route-element-builder.js';
49
50
  import { SlotOutlet } from '../client/slot-outlet.js';
50
51
  import { coerceSegmentParams } from './param-coercion.js';
@@ -76,8 +77,8 @@ export interface CapturedEntry {
76
77
  componentId: string;
77
78
  /** `computeComponentCacheKey(callSiteProps, coercedParams)` — names the .flight file. */
78
79
  cacheKey: string;
79
- /** The raw param combo (JSON-safe) — manifest metadata, not the hash input. */
80
- params: Record<string, string | string[]>;
80
+ /** The param combo — manifest metadata, not the hash input. */
81
+ params: CoercedParams;
81
82
  /** Call-site props used for this entry (empty object for zero-prop usages). */
82
83
  props?: Record<string, unknown>;
83
84
  bytes: Uint8Array;
@@ -91,7 +92,7 @@ export interface CapturedEntry {
91
92
  export interface CaptureIssue {
92
93
  componentId: string;
93
94
  routeUrlPath: string;
94
- params: Record<string, string | string[]>;
95
+ params: CoercedParams;
95
96
  message: string;
96
97
  }
97
98
 
@@ -219,7 +220,7 @@ async function loadLazyChunk(chunkFile: string): Promise<void> {
219
220
 
220
221
  // ─── Param enumeration ────────────────────────────────────────────────────
221
222
 
222
- type ParamCombo = Record<string, string | string[]>;
223
+ type ParamCombo = CoercedParams;
223
224
 
224
225
  /**
225
226
  * Resolve the param combos to capture for a route. Static routes have
@@ -333,7 +334,7 @@ interface EntryOutcome {
333
334
  async function renderEntry(
334
335
  registration: PrebuiltRegistration,
335
336
  props: Record<string, unknown>,
336
- coercedParams: Record<string, string | string[]>,
337
+ coercedParams: CoercedParams,
337
338
  timeoutMs: number,
338
339
  isLayout: boolean
339
340
  ): Promise<EntryOutcome> {
@@ -36,6 +36,8 @@
36
36
  import { createElement } from 'react';
37
37
 
38
38
  import { requestContextAls, type RequestContextStore } from './als-registry.js';
39
+ import type { CoercedParams } from '../shared/param-value.js';
40
+ import type { SlotParamsRecord } from '../shared/slot-params.js';
39
41
  import { computeComponentCacheKey, splitSlotProps, tryCanonicalize } from './prebuilt/cache-key.js';
40
42
  import {
41
43
  isChildrenSlotOutletElement,
@@ -172,9 +174,9 @@ const registry = new Map<string, PrebuiltRegistration>();
172
174
  * aliases, …) is unkeyable here too, and this guard can never throw.
173
175
  */
174
176
  function resolveKeyParams(store: {
175
- segmentParams?: Record<string, string | string[]>;
176
- slotParams?: Record<string, Record<string, string | string[]>>;
177
- }): Record<string, string | string[]> | null {
177
+ segmentParams?: CoercedParams;
178
+ slotParams?: SlotParamsRecord;
179
+ }): CoercedParams | null {
178
180
  const main = store.segmentParams ?? {};
179
181
  const slots = store.slotParams;
180
182
  if (!slots || Object.keys(slots).length === 0) return main;