@timber-js/app 0.2.0-alpha.36 → 0.2.0-alpha.38

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 (110) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -1
  3. package/dist/_chunks/{define-cookie-w5GWm_bL.js → define-cookie-BmKbSyp0.js} +4 -4
  4. package/dist/_chunks/{define-cookie-w5GWm_bL.js.map → define-cookie-BmKbSyp0.js.map} +1 -1
  5. package/dist/_chunks/{error-boundary-TYEQJZ1-.js → error-boundary-BAN3751q.js} +1 -1
  6. package/dist/_chunks/{error-boundary-TYEQJZ1-.js.map → error-boundary-BAN3751q.js.map} +1 -1
  7. package/dist/_chunks/{request-context-CZz_T0Bc.js → request-context-BxYIJM24.js} +59 -4
  8. package/dist/_chunks/request-context-BxYIJM24.js.map +1 -0
  9. package/dist/_chunks/segment-context-C6byCyZU.js +69 -0
  10. package/dist/_chunks/segment-context-C6byCyZU.js.map +1 -0
  11. package/dist/_chunks/{tracing-BPyIzIdu.js → tracing-CuXiCP5p.js} +1 -1
  12. package/dist/_chunks/{tracing-BPyIzIdu.js.map → tracing-CuXiCP5p.js.map} +1 -1
  13. package/dist/_chunks/{wrappers-C1SN725w.js → wrappers-C6J0nNji.js} +2 -2
  14. package/dist/_chunks/{wrappers-C1SN725w.js.map → wrappers-C6J0nNji.js.map} +1 -1
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +27 -4
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/cache/index.d.ts +5 -2
  19. package/dist/cache/index.d.ts.map +1 -1
  20. package/dist/cache/index.js +33 -9
  21. package/dist/cache/index.js.map +1 -1
  22. package/dist/cache/singleflight.d.ts +18 -1
  23. package/dist/cache/singleflight.d.ts.map +1 -1
  24. package/dist/cache/timber-cache.d.ts.map +1 -1
  25. package/dist/client/error-boundary.js +1 -1
  26. package/dist/client/index.d.ts +1 -0
  27. package/dist/client/index.d.ts.map +1 -1
  28. package/dist/client/index.js +25 -8
  29. package/dist/client/index.js.map +1 -1
  30. package/dist/client/link.d.ts +15 -1
  31. package/dist/client/link.d.ts.map +1 -1
  32. package/dist/cookies/index.js +1 -1
  33. package/dist/index.d.ts +12 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +53 -4
  36. package/dist/index.js.map +1 -1
  37. package/dist/params/index.js +1 -1
  38. package/dist/plugins/dev-error-overlay.d.ts +26 -1
  39. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  40. package/dist/plugins/entries.d.ts.map +1 -1
  41. package/dist/search-params/index.js +1 -1
  42. package/dist/server/access-gate.d.ts.map +1 -1
  43. package/dist/server/als-registry.d.ts +14 -0
  44. package/dist/server/als-registry.d.ts.map +1 -1
  45. package/dist/server/html-injectors.d.ts +2 -2
  46. package/dist/server/html-injectors.d.ts.map +1 -1
  47. package/dist/server/index.d.ts +4 -2
  48. package/dist/server/index.d.ts.map +1 -1
  49. package/dist/server/index.js +68 -26
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/node-stream-transforms.d.ts +13 -1
  52. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  53. package/dist/server/pipeline.d.ts.map +1 -1
  54. package/dist/server/primitives.d.ts +30 -3
  55. package/dist/server/primitives.d.ts.map +1 -1
  56. package/dist/server/render-timeout.d.ts +51 -0
  57. package/dist/server/render-timeout.d.ts.map +1 -0
  58. package/dist/server/request-context.d.ts +39 -0
  59. package/dist/server/request-context.d.ts.map +1 -1
  60. package/dist/server/route-element-builder.d.ts.map +1 -1
  61. package/dist/server/rsc-entry/helpers.d.ts +46 -3
  62. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  63. package/dist/server/rsc-entry/index.d.ts +6 -1
  64. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  65. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  66. package/dist/server/rsc-entry/rsc-stream.d.ts +3 -0
  67. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  68. package/dist/server/slot-resolver.d.ts +1 -1
  69. package/dist/server/slot-resolver.d.ts.map +1 -1
  70. package/dist/server/ssr-entry.d.ts.map +1 -1
  71. package/dist/server/ssr-render.d.ts +2 -0
  72. package/dist/server/ssr-render.d.ts.map +1 -1
  73. package/dist/server/tree-builder.d.ts +7 -4
  74. package/dist/server/tree-builder.d.ts.map +1 -1
  75. package/dist/shared/merge-search-params.d.ts +22 -0
  76. package/dist/shared/merge-search-params.d.ts.map +1 -0
  77. package/package.json +6 -7
  78. package/src/adapters/nitro.ts +27 -4
  79. package/src/cache/index.ts +5 -2
  80. package/src/cache/singleflight.ts +54 -4
  81. package/src/cache/timber-cache.ts +17 -16
  82. package/src/cli.ts +0 -0
  83. package/src/client/index.ts +1 -0
  84. package/src/client/link.tsx +57 -3
  85. package/src/index.ts +12 -0
  86. package/src/plugins/dev-error-overlay.ts +70 -1
  87. package/src/plugins/dev-server.ts +38 -4
  88. package/src/plugins/entries.ts +1 -0
  89. package/src/server/access-gate.tsx +6 -5
  90. package/src/server/als-registry.ts +14 -0
  91. package/src/server/html-injectors.ts +32 -7
  92. package/src/server/index.ts +7 -0
  93. package/src/server/node-stream-transforms.ts +49 -13
  94. package/src/server/pipeline.ts +6 -0
  95. package/src/server/primitives.ts +47 -5
  96. package/src/server/render-timeout.ts +108 -0
  97. package/src/server/request-context.ts +69 -1
  98. package/src/server/route-element-builder.ts +10 -16
  99. package/src/server/rsc-entry/helpers.ts +122 -3
  100. package/src/server/rsc-entry/index.ts +34 -4
  101. package/src/server/rsc-entry/rsc-payload.ts +11 -3
  102. package/src/server/rsc-entry/rsc-stream.ts +24 -3
  103. package/src/server/slot-resolver.ts +10 -19
  104. package/src/server/ssr-entry.ts +9 -2
  105. package/src/server/ssr-render.ts +94 -13
  106. package/src/server/tree-builder.ts +13 -15
  107. package/src/shared/merge-search-params.ts +48 -0
  108. package/dist/_chunks/request-context-CZz_T0Bc.js.map +0 -1
  109. package/dist/_chunks/segment-context-Dpq2XOKg.js +0 -34
  110. package/dist/_chunks/segment-context-Dpq2XOKg.js.map +0 -1
@@ -65,6 +65,7 @@ import {
65
65
  createDebugChannelSink,
66
66
  escapeHtml,
67
67
  isRscPayloadRequest,
68
+ type DebugComponentEntry,
68
69
  } from './helpers.js';
69
70
  import { parseClientStateTree } from '#/server/state-tree-diff.js';
70
71
  import { buildRscPayloadResponse } from './rsc-payload.js';
@@ -91,18 +92,35 @@ function resolveServerTimingMode(
91
92
 
92
93
  // Dev-only pipeline error handler, set by the dev server after import.
93
94
  // In production this is always undefined — no overhead.
94
- let _devPipelineErrorHandler: ((error: Error, phase: string) => void) | undefined;
95
+ // The third argument provides RSC debug component data (from the Flight
96
+ // debug channel) when available — used by the error overlay to show the
97
+ // server component tree context for render errors.
98
+ let _devPipelineErrorHandler:
99
+ | ((error: Error, phase: string, debugComponents?: DebugComponentEntry[]) => void)
100
+ | undefined;
95
101
 
96
102
  /**
97
103
  * Set the dev pipeline error handler.
98
104
  *
99
105
  * Called by the dev server after importing this module to wire pipeline
100
106
  * errors into the Vite browser error overlay. No-op in production.
107
+ *
108
+ * The handler receives an optional third argument with RSC debug component
109
+ * info — component names, environments, and stack frames from the Flight
110
+ * debug channel. This is only populated for render-phase errors.
101
111
  */
102
- export function setDevPipelineErrorHandler(handler: (error: Error, phase: string) => void): void {
112
+ export function setDevPipelineErrorHandler(
113
+ handler: (error: Error, phase: string, debugComponents?: DebugComponentEntry[]) => void
114
+ ): void {
103
115
  _devPipelineErrorHandler = handler;
104
116
  }
105
117
 
118
+ // Dev-only: holds a getter for the current request's RSC debug components.
119
+ // Updated on each renderRscStream call so the onPipelineError callback can
120
+ // include component tree context for render-phase errors. This is request-
121
+ // scoped by convention — each renderRoute call sets it before returning.
122
+ let _lastDebugComponentsGetter: (() => DebugComponentEntry[]) | undefined;
123
+
106
124
  /**
107
125
  * Create the RSC request handler from the route manifest.
108
126
  *
@@ -231,7 +249,15 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
231
249
  serverTiming: resolveServerTimingMode(runtimeConfig, isDev),
232
250
  onPipelineError: isDev
233
251
  ? (error: Error, phase: string) => {
234
- if (_devPipelineErrorHandler) _devPipelineErrorHandler(error, phase);
252
+ if (_devPipelineErrorHandler) {
253
+ // For render-phase errors, include RSC debug component data
254
+ // from the Flight debug channel (if available from the current request).
255
+ const debugComponents =
256
+ phase === 'render' && _lastDebugComponentsGetter
257
+ ? _lastDebugComponentsGetter()
258
+ : undefined;
259
+ _devPipelineErrorHandler(error, phase, debugComponents);
260
+ }
235
261
  }
236
262
  : undefined,
237
263
  renderFallbackError: (error, req, responseHeaders) =>
@@ -441,7 +467,11 @@ async function renderRoute(
441
467
 
442
468
  // Render to RSC Flight stream with signal tracking.
443
469
  const _rscStart = performance.now();
444
- const { rscStream, signals } = renderRscStream(element, _req);
470
+ const { rscStream, signals, getDebugComponents } = renderRscStream(element, _req);
471
+
472
+ // Store the debug components getter so onPipelineError can include
473
+ // component tree context for render-phase errors (dev mode only).
474
+ _lastDebugComponentsGetter = getDebugComponents;
445
475
  recordTiming({
446
476
  name: 'rsc-init',
447
477
  dur: Math.round(performance.now() - _rscStart),
@@ -103,9 +103,17 @@ export async function buildRscPayloadResponse(
103
103
  }
104
104
 
105
105
  // Extract the first chunk from the race result.
106
- // If the signal won the race, read the first chunk now (the stream
107
- // was already cancelled above, but we need a firstRead shape below).
108
- const firstRead = first.type === 'data' ? first.chunk : await reader.read();
106
+ // If the signal won the race but neither redirect nor deny was detected
107
+ // (edge case), cancel the reader immediately rather than issuing a bare
108
+ // read() that could hang forever if the RSC stream has stalled.
109
+ // See TIM-519.
110
+ let firstRead: ReadableStreamReadResult<Uint8Array>;
111
+ if (first.type === 'data') {
112
+ firstRead = first.chunk;
113
+ } else {
114
+ await reader.cancel();
115
+ firstRead = { done: true, value: undefined };
116
+ }
109
117
 
110
118
  // Reconstruct the stream: prepend the buffered first chunk,
111
119
  // then continue piping from the original reader.
@@ -16,8 +16,14 @@ import { logRenderError } from '#/server/logger.js';
16
16
  import { DenySignal, RedirectSignal, RenderError } from '#/server/primitives.js';
17
17
  import { checkAndWarnRscPropError } from '#/server/rsc-prop-warnings.js';
18
18
 
19
- import { createDebugChannelSink, isAbortError } from './helpers.js';
19
+ import {
20
+ createDebugChannelSink,
21
+ createDebugChannelCollector,
22
+ isAbortError,
23
+ type DebugComponentEntry,
24
+ } from './helpers.js';
20
25
  import { isDebug } from '#/server/debug.js';
26
+ import { isDevMode } from '#/server/debug.js';
21
27
 
22
28
  /**
23
29
  * Mutable signal state captured during RSC rendering.
@@ -40,6 +46,8 @@ export interface RenderSignals {
40
46
  export interface RscStreamResult {
41
47
  rscStream: ReadableStream<Uint8Array> | undefined;
42
48
  signals: RenderSignals;
49
+ /** Dev-only: server component debug info from the Flight debug channel. */
50
+ getDebugComponents?: () => DebugComponentEntry[];
43
51
  }
44
52
 
45
53
  /**
@@ -62,6 +70,10 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
62
70
 
63
71
  let rscStream: ReadableStream<Uint8Array> | undefined;
64
72
 
73
+ // In dev mode, collect debug channel data for the error overlay.
74
+ // In production, use the discard sink (no overhead).
75
+ const debugChannel = isDevMode() ? createDebugChannelCollector() : createDebugChannelSink();
76
+
65
77
  try {
66
78
  rscStream = renderToReadableStream(
67
79
  element,
@@ -132,7 +144,7 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
132
144
  }
133
145
  logRenderError({ method: req.method, path: new URL(req.url).pathname, error });
134
146
  },
135
- debugChannel: createDebugChannelSink(),
147
+ debugChannel,
136
148
  },
137
149
  {
138
150
  onClientReference(info: { id: string; name: string; deps: unknown }) {
@@ -160,5 +172,14 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
160
172
  }
161
173
  }
162
174
 
163
- return { rscStream, signals };
175
+ return {
176
+ rscStream,
177
+ signals,
178
+ // Expose the debug channel collector's getComponents in dev mode.
179
+ // The caller can retrieve component tree info when handling errors.
180
+ getDebugComponents:
181
+ 'getComponents' in debugChannel
182
+ ? (debugChannel as { getComponents: () => DebugComponentEntry[] }).getComponents
183
+ : undefined,
184
+ };
164
185
  }
@@ -45,13 +45,12 @@ async function loadComponent(loader: {
45
45
  */
46
46
  async function renderDefaultFallback(
47
47
  slotNode: ManifestSegmentNode,
48
- paramsPromise: Promise<Record<string, string | string[]>>,
49
48
  h: CreateElementFn
50
49
  ): Promise<React.ReactElement | null> {
51
50
  if (!slotNode.default) return null;
52
51
  const DefaultComp = await loadComponent(slotNode.default);
53
52
  if (!DefaultComp) return null;
54
- return h(DefaultComp, { params: paramsPromise });
53
+ return h(DefaultComp, {});
55
54
  }
56
55
 
57
56
  // ─── Segment Tree Matching ──────────────────────────────────────────────────
@@ -153,7 +152,6 @@ function walkSegmentTree(
153
152
  export async function resolveSlotElement(
154
153
  slotNode: ManifestSegmentNode,
155
154
  match: RouteMatch,
156
- paramsPromise: Promise<Record<string, string | string[]>>,
157
155
  h: CreateElementFn,
158
156
  interception?: InterceptionContext
159
157
  ): Promise<React.ReactElement | null> {
@@ -174,7 +172,7 @@ export async function resolveSlotElement(
174
172
  // degrade to default.tsx or null — not crash the page. This matches
175
173
  // Next.js behavior. See design/02-rendering-pipeline.md
176
174
  // §"Slot Access Failure = Graceful Degradation"
177
- const denyFallback = await renderDefaultFallback(slotNode, paramsPromise, h);
175
+ const denyFallback = await renderDefaultFallback(slotNode, h);
178
176
 
179
177
  // Wrap the slot page to catch DenySignal (from notFound() or deny())
180
178
  // at the component level. This prevents the signal from reaching the
@@ -192,23 +190,21 @@ export async function resolveSlotElement(
192
190
  }
193
191
  };
194
192
 
195
- let element: React.ReactElement = h(SafeSlotPage, {
196
- params: paramsPromise,
197
- });
193
+ let element: React.ReactElement = h(SafeSlotPage, {});
198
194
 
199
195
  // Wrap with error boundaries and layouts from intermediate slot segments
200
196
  // (everything between slot root and leaf). Process innermost-first, same
201
197
  // order as route-element-builder.ts handles main segments. The slot root
202
198
  // (index 0) is handled separately after the access gate below.
203
- element = await wrapWithIntermediateSegments(slotMatch.chain, element, paramsPromise, h);
199
+ element = await wrapWithIntermediateSegments(slotMatch.chain, element, h);
204
200
 
205
201
  // Wrap in SlotAccessGate if slot root has access.ts.
206
202
  // On denial: denied.tsx → default.tsx → null (graceful degradation).
207
203
  // See design/04-authorization.md §"Slot-Level Auth".
208
- element = await wrapWithAccessGate(slotNode, element, paramsPromise, h);
204
+ element = await wrapWithAccessGate(slotNode, element, h);
209
205
 
210
206
  // Wrap with slot root's layout (outermost, outside access gate)
211
- element = await wrapWithLayout(slotNode, element, paramsPromise, h);
207
+ element = await wrapWithLayout(slotNode, element, h);
212
208
 
213
209
  // Wrap with slot root's error boundaries (outermost)
214
210
  element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
@@ -231,7 +227,7 @@ export async function resolveSlotElement(
231
227
  }
232
228
 
233
229
  // No matching page — render default.tsx fallback
234
- return renderDefaultFallback(slotNode, paramsPromise, h);
230
+ return renderDefaultFallback(slotNode, h);
235
231
  }
236
232
 
237
233
  // ─── Element Wrapping Helpers ───────────────────────────────────────────────
@@ -244,13 +240,12 @@ export async function resolveSlotElement(
244
240
  async function wrapWithIntermediateSegments(
245
241
  chain: ManifestSegmentNode[],
246
242
  element: React.ReactElement,
247
- paramsPromise: Promise<Record<string, string | string[]>>,
248
243
  h: CreateElementFn
249
244
  ): Promise<React.ReactElement> {
250
245
  for (let i = chain.length - 1; i > 0; i--) {
251
246
  const seg = chain[i];
252
247
  element = await wrapSegmentWithErrorBoundaries(seg, element, h);
253
- element = await wrapWithLayout(seg, element, paramsPromise, h);
248
+ element = await wrapWithLayout(seg, element, h);
254
249
  }
255
250
  return element;
256
251
  }
@@ -261,13 +256,12 @@ async function wrapWithIntermediateSegments(
261
256
  async function wrapWithLayout(
262
257
  node: ManifestSegmentNode,
263
258
  element: React.ReactElement,
264
- paramsPromise: Promise<Record<string, string | string[]>>,
265
259
  h: CreateElementFn
266
260
  ): Promise<React.ReactElement> {
267
261
  if (!node.layout) return element;
268
262
  const Layout = await loadComponent(node.layout);
269
263
  if (!Layout) return element;
270
- return h(Layout, { params: paramsPromise, children: element });
264
+ return h(Layout, { children: element });
271
265
  }
272
266
 
273
267
  /**
@@ -277,7 +271,6 @@ async function wrapWithLayout(
277
271
  async function wrapWithAccessGate(
278
272
  slotNode: ManifestSegmentNode,
279
273
  element: React.ReactElement,
280
- paramsPromise: Promise<Record<string, string | string[]>>,
281
274
  h: CreateElementFn
282
275
  ): Promise<React.ReactElement> {
283
276
  if (!slotNode.access) return element;
@@ -295,12 +288,10 @@ async function wrapWithAccessGate(
295
288
  // Extract slot name from the directory name (strip @ prefix)
296
289
  const slotName = slotNode.segmentName?.replace(/^@/, '') ?? '';
297
290
 
298
- const defaultFallback = await renderDefaultFallback(slotNode, paramsPromise, h);
299
- const params = await paramsPromise;
291
+ const defaultFallback = await renderDefaultFallback(slotNode, h);
300
292
 
301
293
  return h(SlotAccessGate, {
302
294
  accessFn,
303
- params,
304
295
  DeniedComponent,
305
296
  slotName,
306
297
  createElement: h,
@@ -243,12 +243,15 @@ export async function handleSsr(
243
243
  PassThrough,
244
244
  } = _nodeStreamImports;
245
245
 
246
+ const renderTimeoutMs = _runtimeConfig.renderTimeoutMs ?? undefined;
247
+
246
248
  let nodeHtmlStream: import('node:stream').Readable;
247
249
  try {
248
250
  nodeHtmlStream = await renderSsrNodeStream(wrappedElement, {
249
251
  bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
250
252
  deferSuspenseFor: navContext.deferSuspenseFor,
251
253
  signal: navContext.signal,
254
+ renderTimeoutMs,
252
255
  });
253
256
  } catch (renderError) {
254
257
  console.error(
@@ -266,7 +269,9 @@ export async function handleSsr(
266
269
  // element is <html>, so no framework-level doctype prepend needed.
267
270
  const errorHandler = createNodeErrorHandler(navContext.signal);
268
271
  const headInjector = createNodeHeadInjector(navContext.headHtml);
269
- const flightInjector = createNodeFlightInjector(navContext.rscStream);
272
+ const flightInjector = createNodeFlightInjector(navContext.rscStream, {
273
+ renderTimeoutMs,
274
+ });
270
275
 
271
276
  const output = new PassThrough();
272
277
  pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
@@ -289,12 +294,14 @@ export async function handleSsr(
289
294
  }
290
295
 
291
296
  // Web Streams path (CF Workers / fallback)
297
+ const renderTimeoutMs = _runtimeConfig.renderTimeoutMs ?? undefined;
292
298
  let htmlStream: ReadableStream<Uint8Array>;
293
299
  try {
294
300
  htmlStream = await renderSsrStream(wrappedElement, {
295
301
  bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
296
302
  deferSuspenseFor: navContext.deferSuspenseFor,
297
303
  signal: navContext.signal,
304
+ renderTimeoutMs,
298
305
  });
299
306
  } catch (renderError) {
300
307
  console.error(
@@ -312,7 +319,7 @@ export async function handleSsr(
312
319
  // Inject metadata into <head>, then interleave RSC payload chunks
313
320
  // into the body as they arrive from the tee'd RSC stream.
314
321
  let outputStream = injectHead(htmlStream, navContext.headHtml);
315
- outputStream = injectRscPayload(outputStream, navContext.rscStream);
322
+ outputStream = injectRscPayload(outputStream, navContext.rscStream, renderTimeoutMs);
316
323
  const _pipelineEnd = performance.now();
317
324
 
318
325
  navContext._ssrTimings = {
@@ -24,6 +24,7 @@ import type { ReactNode } from 'react';
24
24
  import { renderToReadableStream } from 'react-dom/server';
25
25
 
26
26
  import { formatSsrError } from './error-formatter.js';
27
+ import { createRenderTimeout, RenderTimeoutError } from './render-timeout.js';
27
28
 
28
29
  /**
29
30
  * Inline script that injects <meta name="robots" content="noindex"> into <head>.
@@ -105,7 +106,12 @@ try {
105
106
  */
106
107
  export async function renderSsrStream(
107
108
  element: ReactNode,
108
- options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
109
+ options?: {
110
+ bootstrapScriptContent?: string;
111
+ deferSuspenseFor?: number;
112
+ signal?: AbortSignal;
113
+ renderTimeoutMs?: number;
114
+ }
109
115
  ): Promise<ReadableStream<Uint8Array>> {
110
116
  return renderViaReadableStream(element, options);
111
117
  }
@@ -130,17 +136,25 @@ export const useNodeStreams = _useNodeStreams;
130
136
  */
131
137
  export async function renderSsrNodeStream(
132
138
  element: ReactNode,
133
- options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
139
+ options?: {
140
+ bootstrapScriptContent?: string;
141
+ deferSuspenseFor?: number;
142
+ signal?: AbortSignal;
143
+ renderTimeoutMs?: number;
144
+ }
134
145
  ): Promise<import('node:stream').Readable> {
135
146
  const signal = options?.signal;
136
147
  const deferMs = options?.deferSuspenseFor;
148
+ const timeoutMs = options?.renderTimeoutMs;
137
149
 
138
150
  return new Promise<import('node:stream').Readable>((resolve, reject) => {
139
151
  const passthrough = new _PassThrough!();
140
152
 
141
153
  let allReadyResolve: (() => void) | null = null;
142
- const allReady = new Promise<void>((r) => {
143
- allReadyResolve = r;
154
+ let allReadyReject: ((reason?: unknown) => void) | null = null;
155
+ const allReady = new Promise<void>((resolve, reject) => {
156
+ allReadyResolve = resolve;
157
+ allReadyReject = reject;
144
158
  });
145
159
  allReady.catch(() => {});
146
160
 
@@ -164,6 +178,10 @@ export async function renderSsrNodeStream(
164
178
  },
165
179
 
166
180
  onShellError(error: unknown) {
181
+ // Reject allReady so the render timeout is cancelled.
182
+ // Without this, a pre-shell failure leaves the timer
183
+ // running for the full timeout window.
184
+ allReadyReject?.(error);
167
185
  reject(error);
168
186
  },
169
187
 
@@ -173,6 +191,9 @@ export async function renderSsrNodeStream(
173
191
  },
174
192
  });
175
193
 
194
+ // Wire abort to both request signal AND render timeout.
195
+ // If the client stays connected but a downstream fetch hangs,
196
+ // the timeout ensures abort() is eventually called.
176
197
  if (signal) {
177
198
  if (signal.aborted) {
178
199
  abort();
@@ -180,6 +201,28 @@ export async function renderSsrNodeStream(
180
201
  signal.addEventListener('abort', () => abort(), { once: true });
181
202
  }
182
203
  }
204
+
205
+ if (timeoutMs && timeoutMs > 0) {
206
+ const renderTimeout = createRenderTimeout(timeoutMs, signal);
207
+ renderTimeout.signal.addEventListener(
208
+ 'abort',
209
+ () => {
210
+ console.error(
211
+ `[timber] SSR render timed out after ${timeoutMs}ms — aborting. ` +
212
+ 'A Suspense component or downstream fetch may be hanging.'
213
+ );
214
+ abort(renderTimeout.signal.reason);
215
+ },
216
+ { once: true }
217
+ );
218
+ // Cancel the timeout when the render completes OR on pre-shell
219
+ // failure. Without the catch branch, onShellError → reject()
220
+ // would leave the timer running for the full timeout window.
221
+ allReady.then(
222
+ () => renderTimeout.cancel(),
223
+ () => renderTimeout.cancel()
224
+ );
225
+ }
183
226
  });
184
227
  }
185
228
 
@@ -199,21 +242,59 @@ export function nodeReadableToWeb(
199
242
 
200
243
  async function renderViaReadableStream(
201
244
  element: ReactNode,
202
- options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
245
+ options?: {
246
+ bootstrapScriptContent?: string;
247
+ deferSuspenseFor?: number;
248
+ signal?: AbortSignal;
249
+ renderTimeoutMs?: number;
250
+ }
203
251
  ): Promise<ReadableStream<Uint8Array>> {
204
252
  const signal = options?.signal;
205
- const stream = await renderToReadableStream(element, {
206
- bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
207
- signal,
208
- onError(error: unknown) {
209
- if (isAbortError(error) || signal?.aborted) return;
210
- console.error('[timber] SSR render error:', formatSsrError(error));
211
- },
212
- });
253
+ const timeoutMs = options?.renderTimeoutMs;
254
+
255
+ // If a render timeout is configured, create a combined signal that
256
+ // fires on either request abort OR timeout — whichever comes first.
257
+ let renderTimeout: import('./render-timeout.js').RenderTimeout | null = null;
258
+ let effectiveSignal = signal;
259
+ if (timeoutMs && timeoutMs > 0) {
260
+ renderTimeout = createRenderTimeout(timeoutMs, signal);
261
+ effectiveSignal = renderTimeout.signal;
262
+ }
263
+
264
+ let stream: Awaited<ReturnType<typeof renderToReadableStream>>;
265
+ try {
266
+ stream = await renderToReadableStream(element, {
267
+ bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
268
+ signal: effectiveSignal,
269
+ onError(error: unknown) {
270
+ if (isAbortError(error) || effectiveSignal?.aborted) return;
271
+ if (error instanceof RenderTimeoutError) {
272
+ console.error(
273
+ `[timber] SSR render timed out after ${timeoutMs}ms — aborting. ` +
274
+ 'A Suspense component or downstream fetch may be hanging.'
275
+ );
276
+ return;
277
+ }
278
+ console.error('[timber] SSR render error:', formatSsrError(error));
279
+ },
280
+ });
281
+ } catch (error) {
282
+ // Pre-shell failure (e.g. RSC stream error). Cancel the render
283
+ // timeout so it doesn't leak a timer + abort callback for the
284
+ // full timeout window. Under repeated pre-shell failures this
285
+ // would accumulate unnecessary timers.
286
+ renderTimeout?.cancel();
287
+ throw error;
288
+ }
213
289
 
214
290
  // Prevent unhandled promise rejection from streaming-phase errors.
215
291
  stream.allReady.catch(() => {});
216
292
 
293
+ // Cancel the render timeout once allReady resolves (render completed).
294
+ if (renderTimeout) {
295
+ stream.allReady.then(() => renderTimeout!.cancel()).catch(() => renderTimeout!.cancel());
296
+ }
297
+
217
298
  // deferSuspenseFor hold: delay the first read so React can resolve
218
299
  // fast-completing Suspense boundaries before we read the shell HTML.
219
300
  // See design/05-streaming.md §"deferSuspenseFor"
@@ -46,8 +46,13 @@ export type SlotElements = Map<string, ReactElement>;
46
46
  export interface TreeBuilderConfig {
47
47
  /** The matched segment chain from root to leaf. */
48
48
  segments: SegmentNode[];
49
- /** Route params extracted by the matcher (catch-all segments produce string[]). */
50
- params: Record<string, string | string[]>;
49
+ /**
50
+ * Route params extracted by the matcher (catch-all segments produce string[]).
51
+ * @deprecated Params are now accessed via rawSegmentParams() from ALS.
52
+ * This field is kept for backward compatibility but is no longer used
53
+ * by the tree builder itself.
54
+ */
55
+ params?: Record<string, string | string[]>;
51
56
  /** Loads a route file's module. */
52
57
  loadModule: ModuleLoader;
53
58
  /** React.createElement or equivalent. */
@@ -76,7 +81,6 @@ export interface TreeBuilderConfig {
76
81
  */
77
82
  export interface AccessGateProps {
78
83
  accessFn: (ctx: { params: Record<string, string | string[]> }) => unknown;
79
- params: Record<string, string | string[]>;
80
84
  /** Segment name for dev logging (e.g. "authenticated", "dashboard"). */
81
85
  segmentName?: string;
82
86
  /**
@@ -102,7 +106,6 @@ export interface AccessGateProps {
102
106
  */
103
107
  export interface SlotAccessGateProps {
104
108
  accessFn: (ctx: { params: Record<string, string | string[]> }) => unknown;
105
- params: Record<string, string | string[]>;
106
109
  /** The denied.tsx component (not a pre-built element). null if no denied.tsx exists. */
107
110
  DeniedComponent: ((...args: unknown[]) => unknown) | null;
108
111
  /** Slot directory name without @ prefix (e.g. "admin", "sidebar"). */
@@ -149,7 +152,7 @@ export interface TreeBuildResult {
149
152
  * Parallel slots are resolved at each layout level and composed as named props.
150
153
  */
151
154
  export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeBuildResult> {
152
- const { segments, params, loadModule, createElement, errorBoundaryComponent } = config;
155
+ const { segments, loadModule, createElement, errorBoundaryComponent } = config;
153
156
 
154
157
  if (segments.length === 0) {
155
158
  throw new Error('[timber] buildElementTree: empty segment chain');
@@ -173,8 +176,8 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
173
176
  );
174
177
  }
175
178
 
176
- // Build the page element with params prop
177
- let element: ReactElement = createElement(PageComponent, { params });
179
+ // Build the page element params are accessed via rawSegmentParams() from ALS
180
+ let element: ReactElement = createElement(PageComponent, {});
178
181
 
179
182
  // Build tree bottom-up: wrap page, then walk segments from leaf to root
180
183
  for (let i = segments.length - 1; i >= 0; i--) {
@@ -195,7 +198,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
195
198
  const accessFn = accessModule.default as AccessGateProps['accessFn'];
196
199
  element = createElement('timber:access-gate', {
197
200
  accessFn,
198
- params,
199
201
  segmentName: segment.segmentName,
200
202
  children: element,
201
203
  } satisfies AccessGateProps);
@@ -215,7 +217,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
215
217
  for (const [slotName, slotNode] of segment.slots) {
216
218
  slotProps[slotName] = await buildSlotElement(
217
219
  slotNode,
218
- params,
219
220
  loadModule,
220
221
  createElement,
221
222
  errorBoundaryComponent
@@ -225,7 +226,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
225
226
 
226
227
  element = createElement(LayoutComponent, {
227
228
  ...slotProps,
228
- params,
229
229
  children: element,
230
230
  });
231
231
  }
@@ -245,7 +245,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
245
245
  */
246
246
  async function buildSlotElement(
247
247
  slotNode: SegmentNode,
248
- params: Record<string, string | string[]>,
249
248
  loadModule: ModuleLoader,
250
249
  createElement: CreateElement,
251
250
  errorBoundaryComponent: unknown
@@ -262,10 +261,10 @@ async function buildSlotElement(
262
261
 
263
262
  // If no page, render default.tsx or null
264
263
  if (!PageComponent) {
265
- return DefaultComponent ? createElement(DefaultComponent, { params }) : null;
264
+ return DefaultComponent ? createElement(DefaultComponent, {}) : null;
266
265
  }
267
266
 
268
- let element: ReactElement = createElement(PageComponent, { params });
267
+ let element: ReactElement = createElement(PageComponent, {});
269
268
 
270
269
  // Wrap in error boundaries
271
270
  element = await wrapWithErrorBoundaries(
@@ -287,11 +286,10 @@ async function buildSlotElement(
287
286
  const DeniedComponent =
288
287
  (deniedModule?.default as ((...args: unknown[]) => ReactElement) | undefined) ?? null;
289
288
 
290
- const defaultFallback = DefaultComponent ? createElement(DefaultComponent, { params }) : null;
289
+ const defaultFallback = DefaultComponent ? createElement(DefaultComponent, {}) : null;
291
290
 
292
291
  element = createElement('timber:slot-access-gate', {
293
292
  accessFn,
294
- params,
295
293
  DeniedComponent,
296
294
  slotName: slotNode.segmentName.replace(/^@/, ''),
297
295
  createElement,
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared utility for merging preserved search params into a target URL.
3
+ *
4
+ * Used by both <Link> (client) and redirect() (server). Extracted to a shared
5
+ * module to avoid importing client code ('use client') from server modules.
6
+ */
7
+
8
+ /**
9
+ * Merge preserved search params from the current URL into a target href.
10
+ *
11
+ * When `preserve` is `true`, all current search params are merged.
12
+ * When `preserve` is a `string[]`, only the named params are merged.
13
+ *
14
+ * The target href's own search params take precedence — preserved params
15
+ * are only added if the target doesn't already define them.
16
+ *
17
+ * @param targetHref - The resolved target href (may already contain query string)
18
+ * @param currentSearch - The current URL's search string (e.g. "?private=access&page=2")
19
+ * @param preserve - `true` to preserve all, or `string[]` to preserve specific params
20
+ * @returns The target href with preserved search params merged in
21
+ */
22
+ export function mergePreservedSearchParams(
23
+ targetHref: string,
24
+ currentSearch: string,
25
+ preserve: true | string[]
26
+ ): string {
27
+ const currentParams = new URLSearchParams(currentSearch);
28
+ if (currentParams.size === 0) return targetHref;
29
+
30
+ // Split target into path and existing query
31
+ const qIdx = targetHref.indexOf('?');
32
+ const targetPath = qIdx >= 0 ? targetHref.slice(0, qIdx) : targetHref;
33
+ const targetParams = new URLSearchParams(qIdx >= 0 ? targetHref.slice(qIdx + 1) : '');
34
+
35
+ // Collect params to preserve (that aren't already in the target)
36
+ const merged = new URLSearchParams(targetParams);
37
+ for (const [key, value] of currentParams) {
38
+ // Only preserve if: (a) not already in target, and (b) included in preserve list
39
+ if (!targetParams.has(key)) {
40
+ if (preserve === true || preserve.includes(key)) {
41
+ merged.append(key, value);
42
+ }
43
+ }
44
+ }
45
+
46
+ const qs = merged.toString();
47
+ return qs ? `${targetPath}?${qs}` : targetPath;
48
+ }