@timber-js/app 0.2.0-alpha.35 → 0.2.0-alpha.37

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 (76) 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/cache/index.js +1 -1
  16. package/dist/client/error-boundary.js +1 -1
  17. package/dist/client/index.d.ts +1 -0
  18. package/dist/client/index.d.ts.map +1 -1
  19. package/dist/client/index.js +25 -8
  20. package/dist/client/index.js.map +1 -1
  21. package/dist/client/link.d.ts +15 -1
  22. package/dist/client/link.d.ts.map +1 -1
  23. package/dist/cookies/index.js +1 -1
  24. package/dist/params/index.js +1 -1
  25. package/dist/search-params/index.js +1 -1
  26. package/dist/server/access-gate.d.ts.map +1 -1
  27. package/dist/server/als-registry.d.ts +14 -0
  28. package/dist/server/als-registry.d.ts.map +1 -1
  29. package/dist/server/deny-renderer.d.ts.map +1 -1
  30. package/dist/server/flight-scripts.d.ts +39 -0
  31. package/dist/server/flight-scripts.d.ts.map +1 -0
  32. package/dist/server/html-injectors.d.ts +3 -9
  33. package/dist/server/html-injectors.d.ts.map +1 -1
  34. package/dist/server/index.d.ts +2 -2
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +42 -26
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  39. package/dist/server/pipeline.d.ts.map +1 -1
  40. package/dist/server/primitives.d.ts +30 -3
  41. package/dist/server/primitives.d.ts.map +1 -1
  42. package/dist/server/request-context.d.ts +39 -0
  43. package/dist/server/request-context.d.ts.map +1 -1
  44. package/dist/server/route-element-builder.d.ts.map +1 -1
  45. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  46. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  47. package/dist/server/slot-resolver.d.ts +1 -1
  48. package/dist/server/slot-resolver.d.ts.map +1 -1
  49. package/dist/server/tree-builder.d.ts +7 -4
  50. package/dist/server/tree-builder.d.ts.map +1 -1
  51. package/dist/shared/merge-search-params.d.ts +22 -0
  52. package/dist/shared/merge-search-params.d.ts.map +1 -0
  53. package/package.json +6 -7
  54. package/src/cli.ts +0 -0
  55. package/src/client/browser-entry.ts +3 -12
  56. package/src/client/index.ts +1 -0
  57. package/src/client/link.tsx +57 -3
  58. package/src/server/access-gate.tsx +6 -5
  59. package/src/server/als-registry.ts +14 -0
  60. package/src/server/deny-renderer.ts +2 -1
  61. package/src/server/flight-scripts.ts +59 -0
  62. package/src/server/html-injectors.ts +8 -32
  63. package/src/server/index.ts +3 -0
  64. package/src/server/node-stream-transforms.ts +8 -24
  65. package/src/server/pipeline.ts +6 -0
  66. package/src/server/primitives.ts +47 -5
  67. package/src/server/request-context.ts +69 -1
  68. package/src/server/route-element-builder.ts +10 -16
  69. package/src/server/rsc-entry/error-renderer.ts +2 -1
  70. package/src/server/rsc-entry/ssr-renderer.ts +9 -1
  71. package/src/server/slot-resolver.ts +10 -19
  72. package/src/server/tree-builder.ts +13 -15
  73. package/src/shared/merge-search-params.ts +48 -0
  74. package/dist/_chunks/request-context-CZz_T0Bc.js.map +0 -1
  75. package/dist/_chunks/segment-context-Dpq2XOKg.js +0 -34
  76. package/dist/_chunks/segment-context-Dpq2XOKg.js.map +0 -1
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { createMachine } from '../utils/state-machine.js';
11
+ import { flightChunkScript } from './flight-scripts.js';
11
12
  import {
12
13
  flightInjectionTransitions,
13
14
  isSuffixStripped,
@@ -105,22 +106,6 @@ export function injectScripts(
105
106
  return createInjector(stream, scriptsHtml, '</body>');
106
107
  }
107
108
 
108
- /**
109
- * Escape a string for safe embedding inside a `<script>` tag within
110
- * a JSON-encoded value.
111
- *
112
- * Only needs to prevent `</script>` from closing the tag early and
113
- * handle U+2028/U+2029 (line/paragraph separators valid in JSON but
114
- * historically problematic in JS). Since we use JSON.stringify for the
115
- * outer encoding, we only escape `<` and the line separators.
116
- */
117
- function htmlEscapeJsonString(str: string): string {
118
- return str
119
- .replace(/</g, '\\u003c')
120
- .replace(/\u2028/g, '\\u2028')
121
- .replace(/\u2029/g, '\\u2029');
122
- }
123
-
124
109
  /**
125
110
  * Transform an RSC Flight stream into a stream of inline `<script>` tags.
126
111
  *
@@ -128,15 +113,9 @@ function htmlEscapeJsonString(str: string): string {
128
113
  * transform) drives reads from the RSC stream on demand. No background
129
114
  * reader, no shared mutable arrays, no race conditions.
130
115
  *
131
- * Each RSC chunk becomes:
132
- * <script>(self.__timber_f=self.__timber_f||[]).push([1,"escaped_chunk"])</script>
133
- *
134
- * The first chunk emitted is the bootstrap signal [0] which the client
135
- * uses to initialize its buffer.
136
- *
137
- * Uses JSON-encoded typed tuples matching the pattern from Next.js:
138
- * [0] — bootstrap signal
139
- * [1, data] — RSC Flight data chunk (UTF-8 string)
116
+ * Each RSC chunk becomes a `<script>self.__timber_f.push([1,"data"])</script>`.
117
+ * The init script (which creates __timber_f) is in `<head>` via
118
+ * flightInitScript() — see flight-scripts.ts.
140
119
  */
141
120
  export function createInlinedRscStream(
142
121
  rscStream: ReadableStream<Uint8Array>
@@ -146,11 +125,9 @@ export function createInlinedRscStream(
146
125
  const decoder = new TextDecoder('utf-8', { fatal: true });
147
126
 
148
127
  return new ReadableStream<Uint8Array>({
149
- start(controller) {
150
- // Emit bootstrap signal tells the client that __timber_f is active
151
- const bootstrap = `<script>(self.__timber_f=self.__timber_f||[]).push(${htmlEscapeJsonString(JSON.stringify([0]))})</script>`;
152
- controller.enqueue(encoder.encode(bootstrap));
153
- },
128
+ // No bootstrap signal here — the init script is in <head> via
129
+ // flightInitScript() (see flight-scripts.ts). This ensures the
130
+ // __timber_f array exists before any chunk scripts execute.
154
131
  async pull(controller) {
155
132
  try {
156
133
  const { done, value } = await rscReader.read();
@@ -160,8 +137,7 @@ export function createInlinedRscStream(
160
137
  }
161
138
  if (value) {
162
139
  const decoded = decoder.decode(value, { stream: true });
163
- const escaped = htmlEscapeJsonString(JSON.stringify([1, decoded]));
164
- controller.enqueue(encoder.encode(`<script>self.__timber_f.push(${escaped})</script>`));
140
+ controller.enqueue(encoder.encode(flightChunkScript(decoded)));
165
141
  }
166
142
  } catch (error) {
167
143
  controller.error(error);
@@ -13,6 +13,8 @@ export {
13
13
  headers,
14
14
  cookies,
15
15
  rawSearchParams,
16
+ rawSegmentParams,
17
+ setSegmentParams,
16
18
  runWithRequestContext,
17
19
  setMutableCookieContext,
18
20
  markResponseFlushed,
@@ -32,6 +34,7 @@ export {
32
34
  waitUntil,
33
35
  DenySignal,
34
36
  RedirectSignal,
37
+ type RedirectOptions,
35
38
  } from './primitives';
36
39
  export type { RenderErrorDigest, WaitUntilAdapter } from './primitives';
37
40
  export type { JsonSerializable } from './types';
@@ -21,6 +21,7 @@ import { Transform } from 'node:stream';
21
21
  import { createGzip, constants } from 'node:zlib';
22
22
 
23
23
  import { createMachine } from '../utils/state-machine.js';
24
+ import { flightChunkScript } from './flight-scripts.js';
24
25
  import {
25
26
  flightInjectionTransitions,
26
27
  isSuffixStripped,
@@ -90,17 +91,6 @@ export function createNodeHeadInjector(headHtml: string): Transform {
90
91
 
91
92
  // ─── RSC Flight Injection ────────────────────────────────────────────────────
92
93
 
93
- /**
94
- * Escape a string for safe embedding inside a `<script>` tag within
95
- * a JSON-encoded value. Same as htmlEscapeJsonString in html-injectors.ts.
96
- */
97
- function htmlEscapeJsonString(str: string): string {
98
- return str
99
- .replace(/</g, '\\u003c')
100
- .replace(/\u2028/g, '\\u2028')
101
- .replace(/\u2029/g, '\\u2029');
102
- }
103
-
104
94
  /**
105
95
  * Node.js Transform that merges RSC script tags into the HTML stream.
106
96
  *
@@ -157,8 +147,7 @@ export function createNodeFlightInjector(
157
147
  return;
158
148
  }
159
149
  const decoded = decoder.decode(value, { stream: true });
160
- const escaped = htmlEscapeJsonString(JSON.stringify([1, decoded]));
161
- const scriptBuf = Buffer.from(`<script>self.__timber_f.push(${escaped})</script>`, 'utf-8');
150
+ const scriptBuf = Buffer.from(flightChunkScript(decoded), 'utf-8');
162
151
  // Push directly to the transform output — don't wait for an
163
152
  // HTML chunk to trigger drainPending.
164
153
  stream.push(scriptBuf);
@@ -175,13 +164,9 @@ export function createNodeFlightInjector(
175
164
  }
176
165
  }
177
166
 
178
- // Bootstrap script to emit after the first HTML chunk (but before
179
- // any RSC data chunks). Must come AFTER the doctype + <html> so
180
- // browsers don't enter Quirks Mode.
181
- const bootstrapBuf = Buffer.from(
182
- `<script>(self.__timber_f=self.__timber_f||[]).push(${htmlEscapeJsonString(JSON.stringify([0]))})</script>`,
183
- 'utf-8'
184
- );
167
+ // No bootstrap script here the init script is in <head> via
168
+ // flightInitScript() (see flight-scripts.ts). This ensures __timber_f
169
+ // exists before any chunk scripts execute.
185
170
 
186
171
  const transform = new Transform({
187
172
  transform(chunk: Buffer, _encoding, callback) {
@@ -208,11 +193,10 @@ export function createNodeFlightInjector(
208
193
  transform.push(chunk);
209
194
  }
210
195
 
211
- // Emit bootstrap AFTER the first HTML chunk so the doctype and
212
- // <html> tag are the first bytes the browser sees. Then start
213
- // the pull loop to stream RSC data chunks.
196
+ // Start the pull loop on the first HTML chunk to stream RSC
197
+ // data chunks alongside the HTML. The __timber_f init script is
198
+ // already in <head> (via flightInitScript), so no bootstrap needed.
214
199
  if (isFirst) {
215
- transform.push(bootstrapBuf);
216
200
  pullLoop(transform);
217
201
  }
218
202
  callback();
@@ -21,6 +21,7 @@ import {
21
21
  setMutableCookieContext,
22
22
  getSetCookieHeaders,
23
23
  markResponseFlushed,
24
+ setSegmentParams,
24
25
  } from './request-context.js';
25
26
  import {
26
27
  generateTraceId,
@@ -484,6 +485,11 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
484
485
  throw error;
485
486
  }
486
487
 
488
+ // Store coerced segment params in ALS so components can access them
489
+ // via rawSegmentParams() instead of receiving them as a prop.
490
+ // See design/07-routing.md §"params.ts — Convention File for Typed Params"
491
+ setSegmentParams(match.params);
492
+
487
493
  // Stage 3: Leaf middleware.ts (only the leaf route's middleware runs)
488
494
  if (match.middleware) {
489
495
  const ctx: MiddlewareContext = {
@@ -6,6 +6,8 @@
6
6
  import type { JsonSerializable } from './types.js';
7
7
  import { getWaitUntil as _getWaitUntil } from './waituntil-bridge.js';
8
8
  import { isDebug } from './debug.js';
9
+ import { getRequestSearchString } from './request-context.js';
10
+ import { mergePreservedSearchParams } from '#/shared/merge-search-params.js';
9
11
 
10
12
  // ─── Dev-mode validation ────────────────────────────────────────────────────
11
13
 
@@ -209,14 +211,46 @@ export class RedirectSignal extends Error {
209
211
  /** Pattern matching absolute URLs: http(s):// or protocol-relative // */
210
212
  const ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
211
213
 
214
+ /**
215
+ * Options for redirect() — alternative to passing a bare status code.
216
+ */
217
+ export interface RedirectOptions {
218
+ /** HTTP redirect status code (3xx). Defaults to 302. */
219
+ status?: number;
220
+ /**
221
+ * Preserve search params from the current request URL on the redirect target.
222
+ *
223
+ * - `true` — preserve ALL current search params (target params take precedence)
224
+ * - `string[]` — preserve only the named params (e.g. `['private', 'token']`)
225
+ *
226
+ * Target path's own query params always take precedence over preserved ones.
227
+ */
228
+ preserveSearchParams?: true | string[];
229
+ }
230
+
212
231
  /**
213
232
  * Redirect to a relative path. Rejects absolute and protocol-relative URLs.
214
233
  * Use `redirectExternal()` for external redirects with an allow-list.
215
234
  *
216
235
  * @param path - Relative path (e.g. '/login', 'settings', '/login?returnTo=/dash')
217
- * @param status - HTTP redirect status code (3xx). Defaults to 302.
236
+ * @param statusOrOptions - HTTP status code (3xx, default 302) or options object.
237
+ *
238
+ * @example
239
+ * // Simple redirect
240
+ * redirect('/login');
241
+ *
242
+ * // With status code
243
+ * redirect('/login', 301);
244
+ *
245
+ * // With preserved search params
246
+ * redirect(`/docs/${version}/${slug}`, { preserveSearchParams: ['foo'] });
218
247
  */
219
- export function redirect(path: string, status: number = 302): never {
248
+ export function redirect(path: string, statusOrOptions?: number | RedirectOptions): never {
249
+ const status =
250
+ typeof statusOrOptions === 'number' ? statusOrOptions : (statusOrOptions?.status ?? 302);
251
+ const preserveSearchParams =
252
+ typeof statusOrOptions === 'object' ? statusOrOptions.preserveSearchParams : undefined;
253
+
220
254
  if (status < 300 || status > 399) {
221
255
  throw new Error(`redirect() requires a 3xx status code, got ${status}.`);
222
256
  }
@@ -226,7 +260,14 @@ export function redirect(path: string, status: number = 302): never {
226
260
  'Use redirectExternal(url, allowList) for external redirects.'
227
261
  );
228
262
  }
229
- throw new RedirectSignal(path, status);
263
+
264
+ let resolvedPath = path;
265
+ if (preserveSearchParams) {
266
+ const currentSearch = getRequestSearchString();
267
+ resolvedPath = mergePreservedSearchParams(path, currentSearch, preserveSearchParams);
268
+ }
269
+
270
+ throw new RedirectSignal(resolvedPath, status);
230
271
  }
231
272
 
232
273
  /**
@@ -236,9 +277,10 @@ export function redirect(path: string, status: number = 302): never {
236
277
  * will replay POST requests to the new location. This matches Next.js behavior.
237
278
  *
238
279
  * @param path - Relative path (e.g. '/new-page', '/dashboard')
280
+ * @param options - Optional redirect options (e.g. preserveSearchParams).
239
281
  */
240
- export function permanentRedirect(path: string): never {
241
- redirect(path, 308);
282
+ export function permanentRedirect(path: string, options?: Omit<RedirectOptions, 'status'>): never {
283
+ redirect(path, { status: 308, ...options });
242
284
  }
243
285
 
244
286
  /**
@@ -178,6 +178,72 @@ export function rawSearchParams(): Promise<URLSearchParams> {
178
178
  return store.searchParamsPromise;
179
179
  }
180
180
 
181
+ /**
182
+ * Returns a Promise resolving to the current request's coerced segment params.
183
+ *
184
+ * Segment params are set by the pipeline after route matching and param
185
+ * coercion (via params.ts codecs). When no params.ts exists, values are
186
+ * raw strings. When codecs are defined, values are already coerced
187
+ * (e.g., `id` is a `number` if `defineSegmentParams({ id: z.coerce.number() })`).
188
+ *
189
+ * This is the primary way page and layout components access route params:
190
+ *
191
+ * ```ts
192
+ * import { rawSegmentParams } from '@timber-js/app/server'
193
+ *
194
+ * export default async function Page() {
195
+ * const { slug } = await rawSegmentParams()
196
+ * // ...
197
+ * }
198
+ * ```
199
+ *
200
+ * Throws if called outside a request context.
201
+ */
202
+ export function rawSegmentParams(): Promise<Record<string, string | string[]>> {
203
+ const store = requestContextAls.getStore();
204
+ if (!store) {
205
+ throw new Error(
206
+ '[timber] rawSegmentParams() called outside of a request context. ' +
207
+ 'It can only be used in middleware, access checks, server components, and server actions.'
208
+ );
209
+ }
210
+ if (!store.segmentParamsPromise) {
211
+ throw new Error(
212
+ '[timber] rawSegmentParams() called before route matching completed. ' +
213
+ 'Segment params are not available until after the route is matched.'
214
+ );
215
+ }
216
+ return store.segmentParamsPromise;
217
+ }
218
+
219
+ /**
220
+ * Set the segment params promise on the current request context.
221
+ * Called by the pipeline after route matching and param coercion.
222
+ *
223
+ * @internal — framework use only
224
+ */
225
+ export function setSegmentParams(params: Record<string, string | string[]>): void {
226
+ const store = requestContextAls.getStore();
227
+ if (!store) {
228
+ throw new Error('[timber] setSegmentParams() called outside of a request context.');
229
+ }
230
+ store.segmentParamsPromise = Promise.resolve(params);
231
+ }
232
+
233
+ /**
234
+ * Returns the raw search string from the current request URL (e.g. "?foo=bar").
235
+ * Synchronous — safe for use in `redirect()` which throws synchronously.
236
+ *
237
+ * Returns empty string if called outside a request context (non-throwing for
238
+ * use in redirect's optional preserveSearchParams path).
239
+ *
240
+ * @internal — used by redirect() for preserveSearchParams support.
241
+ */
242
+ export function getRequestSearchString(): string {
243
+ const store = requestContextAls.getStore();
244
+ return store?.searchString ?? '';
245
+ }
246
+
181
247
  // ─── Types ────────────────────────────────────────────────────────────────
182
248
 
183
249
  /**
@@ -253,11 +319,13 @@ export interface RequestCookies {
253
319
  */
254
320
  export function runWithRequestContext<T>(req: Request, fn: () => T): T {
255
321
  const originalCopy = new Headers(req.headers);
322
+ const parsedUrl = new URL(req.url);
256
323
  const store: RequestContextStore = {
257
324
  headers: freezeHeaders(req.headers),
258
325
  originalHeaders: originalCopy,
259
326
  cookieHeader: req.headers.get('cookie') ?? '',
260
- searchParamsPromise: Promise.resolve(new URL(req.url).searchParams),
327
+ searchParamsPromise: Promise.resolve(parsedUrl.searchParams),
328
+ searchString: parsedUrl.search,
261
329
  cookieJar: new Map(),
262
330
  flushed: false,
263
331
  mutableContext: false,
@@ -110,7 +110,7 @@ function rejectLegacyGenerateMetadata(mod: Record<string, unknown>, filePath: st
110
110
  ` // Before\n` +
111
111
  ` export async function generateMetadata({ params }) { ... }\n\n` +
112
112
  ` // After\n` +
113
- ` export async function metadata({ params }) { ... }`
113
+ ` export async function metadata() { ... }`
114
114
  );
115
115
  }
116
116
  }
@@ -119,19 +119,21 @@ function rejectLegacyGenerateMetadata(mod: Record<string, unknown>, filePath: st
119
119
  * Extract and resolve metadata from a module (layout or page).
120
120
  * Handles both static metadata objects and async metadata functions.
121
121
  * Returns the resolved Metadata, or null if none exported.
122
+ *
123
+ * Metadata functions no longer receive { params } — they access params
124
+ * via rawSegmentParams() from ALS, same as page/layout components.
122
125
  */
123
126
  async function extractMetadata(
124
127
  mod: Record<string, unknown>,
125
- segment: ManifestSegmentNode,
126
- paramsPromise: Promise<Record<string, string | string[]>>
128
+ segment: ManifestSegmentNode
127
129
  ): Promise<Metadata | null> {
128
130
  if (typeof mod.metadata === 'function') {
129
- type MetadataFn = (props: Record<string, unknown>) => Promise<Metadata>;
131
+ type MetadataFn = () => Promise<Metadata>;
130
132
  return (
131
133
  (await withSpan(
132
134
  'timber.metadata',
133
135
  { 'timber.segment': segment.segmentName ?? segment.urlPath },
134
- () => (mod.metadata as MetadataFn)({ params: paramsPromise })
136
+ () => (mod.metadata as MetadataFn)()
135
137
  )) ?? null
136
138
  );
137
139
  }
@@ -172,9 +174,6 @@ export async function buildRouteElement(
172
174
  ): Promise<RouteElementResult> {
173
175
  const segments = match.segments as unknown as ManifestSegmentNode[];
174
176
 
175
- // Params are passed as a Promise to match Next.js 15+ convention.
176
- const paramsPromise = Promise.resolve(match.params);
177
-
178
177
  // Load all modules along the segment chain
179
178
  const metadataEntries: Array<{ metadata: Metadata; isPage: boolean }> = [];
180
179
  const layoutComponents: LayoutComponentEntry[] = [];
@@ -199,7 +198,7 @@ export async function buildRouteElement(
199
198
  // middleware and rendering. See coerceSegmentParams() in pipeline.ts.
200
199
 
201
200
  rejectLegacyGenerateMetadata(mod, segment.layout.filePath ?? segment.urlPath);
202
- const layoutMetadata = await extractMetadata(mod, segment, paramsPromise);
201
+ const layoutMetadata = await extractMetadata(mod, segment);
203
202
  if (layoutMetadata) {
204
203
  metadataEntries.push({ metadata: layoutMetadata, isPage: false });
205
204
  }
@@ -217,7 +216,7 @@ export async function buildRouteElement(
217
216
  PageComponent = mod.default as (...args: unknown[]) => unknown;
218
217
  }
219
218
  rejectLegacyGenerateMetadata(mod, segment.page.filePath ?? segment.urlPath);
220
- const pageMetadata = await extractMetadata(mod, segment, paramsPromise);
219
+ const pageMetadata = await extractMetadata(mod, segment);
221
220
  if (pageMetadata) {
222
221
  metadataEntries.push({ metadata: pageMetadata, isPage: true });
223
222
  }
@@ -317,9 +316,7 @@ export async function buildRouteElement(
317
316
  );
318
317
  };
319
318
 
320
- let element = h(TracedPage, {
321
- params: paramsPromise,
322
- });
319
+ let element = h(TracedPage, {});
323
320
 
324
321
  // Build a lookup of layout components by segment for O(1) access.
325
322
  const layoutBySegment = new Map(
@@ -399,7 +396,6 @@ export async function buildRouteElement(
399
396
  if (accessFn) {
400
397
  element = h(AccessGate, {
401
398
  accessFn,
402
- params: match.params,
403
399
  segmentName: segment.segmentName,
404
400
  verdict: accessVerdicts.get(i),
405
401
  children: element,
@@ -416,7 +412,6 @@ export async function buildRouteElement(
416
412
  slotProps[slotName] = await resolveSlotElement(
417
413
  slotNode as ManifestSegmentNode,
418
414
  match,
419
- paramsPromise,
420
415
  h,
421
416
  interception
422
417
  );
@@ -447,7 +442,6 @@ export async function buildRouteElement(
447
442
  parallelRouteKeys,
448
443
  children: h(TracedLayout, {
449
444
  ...slotProps,
450
- params: paramsPromise,
451
445
  children: element,
452
446
  }),
453
447
  });
@@ -12,6 +12,7 @@ import { logRenderError } from '#/server/logger.js';
12
12
  import type { ManifestSegmentNode } from '#/server/route-matcher.js';
13
13
  import { DenySignal, RenderError } from '#/server/primitives.js';
14
14
  import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
15
+ import { flightInitScript } from '#/server/flight-scripts.js';
15
16
  import { renderDenyPage } from '#/server/deny-renderer.js';
16
17
  import type { LayoutEntry } from '#/server/deny-renderer.js';
17
18
  import type { NavContext } from '#/server/ssr-entry.js';
@@ -125,7 +126,7 @@ export async function renderErrorPage(
125
126
  searchParams: Object.fromEntries(new URL(req.url).searchParams),
126
127
  statusCode: status,
127
128
  responseHeaders,
128
- headHtml: '',
129
+ headHtml: flightInitScript(),
129
130
  bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
130
131
  rscStream: inlineStream,
131
132
  cookies: getCookiesForSsr(),
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
16
+ import { flightInitScript } from '#/server/flight-scripts.js';
16
17
  import type { LayoutEntry } from '#/server/deny-renderer.js';
17
18
  import { renderDenyPage } from '#/server/deny-renderer.js';
18
19
  import type { RouteMatch } from '#/server/pipeline.js';
@@ -105,7 +106,14 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
105
106
  searchParams: Object.fromEntries(new URL(req.url).searchParams),
106
107
  statusCode: 200,
107
108
  responseHeaders,
108
- headHtml: headHtml + clientBootstrap.preloadLinks + segmentScript + paramsScript,
109
+ headHtml:
110
+ headHtml +
111
+ clientBootstrap.preloadLinks +
112
+ segmentScript +
113
+ paramsScript +
114
+ // Initialize __timber_f in <head> so it exists before any streaming
115
+ // chunk scripts arrive in <body>. See flight-scripts.ts, LOCAL-415.
116
+ (clientJsDisabled ? '' : flightInitScript()),
109
117
  bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
110
118
  // Skip RSC inline stream when client JS is disabled — no client to hydrate.
111
119
  rscStream: clientJsDisabled ? undefined : inlineStream,
@@ -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,