@timber-js/app 0.2.0-alpha.32 → 0.2.0-alpha.34

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.
@@ -1 +1 @@
1
- {"version":3,"file":"node-stream-transforms.d.ts","sourceRoot":"","sources":["../../src/server/node-stream-transforms.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAKxC;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CA+ClE;AAeD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,GAChD,SAAS,CAyGX;AAOD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAwBtE;AAoBD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,cAAc,EAAE,OAAO,EACvB,eAAe,EAAE,OAAO,GACvB,SAAS,GAAG,IAAI,CA0BlB"}
1
+ {"version":3,"file":"node-stream-transforms.d.ts","sourceRoot":"","sources":["../../src/server/node-stream-transforms.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAKxC;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CA+ClE;AAeD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,GAChD,SAAS,CAuHX;AAOD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAwBtE;AAoBD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,cAAc,EAAE,OAAO,EACvB,eAAe,EAAE,OAAO,GACvB,SAAS,GAAG,IAAI,CA8BlB"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA0FA;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAE/F;AAoZD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;8BAtQ3C,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AAwQhD,wBAAiE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA2FA;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAE/F;AAkaD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;8BApR3C,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AAsRhD,wBAAiE"}
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-renderer.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/ssr-renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAGxE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAYrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD,UAAU,gBAAgB;IACxB,GAAG,EAAE,OAAO,CAAC;IACb,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACtC,OAAO,EAAE,aAAa,CAAC;IACvB,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,OAAO,CAAC;IACzB,eAAe,EAAE,qBAAqB,CAAC;IACvC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAsKjF"}
1
+ {"version":3,"file":"ssr-renderer.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/ssr-renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAGxE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAYrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGrD,UAAU,gBAAgB;IACxB,GAAG,EAAE,OAAO,CAAC;IACb,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACtC,OAAO,EAAE,aAAa,CAAC;IACvB,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,OAAO,CAAC;IACzB,eAAe,EAAE,qBAAqB,CAAC;IACvC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAqLjF"}
@@ -53,6 +53,28 @@ export interface NavContext {
53
53
  * to a page-level deny. See LOCAL-298.
54
54
  */
55
55
  _denyHandledByBoundary?: boolean;
56
+ /**
57
+ * Mutable: SSR timing data populated by handleSsr().
58
+ * Read by the RSC entry to record sub-phase Server-Timing entries
59
+ * when `serverTiming: 'detailed'` is configured.
60
+ *
61
+ * This bridges the RSC→SSR environment boundary: the SSR entry populates
62
+ * these fields, the RSC entry reads them after callSsr() returns.
63
+ */
64
+ _ssrTimings?: {
65
+ /** Time to decode RSC stream (createFromReadableStream/createFromNodeStream) */
66
+ decodeMs: number;
67
+ /** Time for Fizz to render the shell (onShellReady) */
68
+ shellMs: number;
69
+ /** Time for pipe() to flush shell bytes to the output stream */
70
+ pipeMs: number;
71
+ /** Time to set up Node.js Transform pipeline / Web Stream transforms */
72
+ pipelineMs: number;
73
+ /** Total SSR time (decode → response ready) */
74
+ totalMs: number;
75
+ /** Whether the Node.js native stream path was used */
76
+ nodeStreams: boolean;
77
+ };
56
78
  }
57
79
  /**
58
80
  * Handle SSR: decode an RSC stream and render it to hydration-ready HTML.
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA0EH;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,iCAAiC;IACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,eAAe,EAAE,OAAO,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACvC;;;0DAGsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;iFAE6E;IAC7E,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;4DACwD;IACxD,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,QAAQ,CAAC,CAyJnB;AAED,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA0EH;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,iCAAiC;IACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,eAAe,EAAE,OAAO,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACvC;;;0DAGsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;iFAE6E;IAC7E,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;4DACwD;IACxD,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAEjC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE;QACZ,gFAAgF;QAChF,QAAQ,EAAE,MAAM,CAAC;QACjB,uDAAuD;QACvD,OAAO,EAAE,MAAM,CAAC;QAChB,gEAAgE;QAChE,MAAM,EAAE,MAAM,CAAC;QACf,wEAAwE;QACxE,UAAU,EAAE,MAAM,CAAC;QACnB,+CAA+C;QAC/C,OAAO,EAAE,MAAM,CAAC;QAChB,sDAAsD;QACtD,WAAW,EAAE,OAAO,CAAC;KACtB,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,QAAQ,CAAC,CAuJnB;AAED,eAAe,SAAS,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-render.d.ts","sourceRoot":"","sources":["../../src/server/ssr-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAqEvC;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IAAE,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC7F,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAErC;AAED,wEAAwE;AACxE,eAAO,MAAM,cAAc,SAAkB,CAAC;AAU9C;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IAAE,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC7F,OAAO,CAAC,OAAO,aAAa,EAAE,QAAQ,CAAC,CAyDzC;AAED,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,OAAO,aAAa,EAAE,QAAQ,GACvC,cAAc,CAAC,UAAU,CAAC,CAE5B;AA0CD;;;;;;;;;;;;GAYG;AACH,2CAA2C;AAC3C,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,MAAM,CAAC,EAAE,WAAW,GACnB,cAAc,CAAC,UAAU,CAAC,CA2B5B;AAWD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,cAAc,CAAC,UAAU,CAAC,EACtC,UAAU,EAAE,MAAM,EAClB,eAAe,EAAE,OAAO,GACvB,QAAQ,CASV"}
1
+ {"version":3,"file":"ssr-render.d.ts","sourceRoot":"","sources":["../../src/server/ssr-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAqEvC;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IAAE,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC7F,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAErC;AAED,wEAAwE;AACxE,eAAO,MAAM,cAAc,SAAkB,CAAC;AAU9C;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IAAE,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC7F,OAAO,CAAC,OAAO,aAAa,EAAE,QAAQ,CAAC,CAkDzC;AAED,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,OAAO,aAAa,EAAE,QAAQ,GACvC,cAAc,CAAC,UAAU,CAAC,CAE5B;AA0CD;;;;;;;;;;;;GAYG;AACH,2CAA2C;AAC3C,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,MAAM,CAAC,EAAE,WAAW,GACnB,cAAc,CAAC,UAAU,CAAC,CA2B5B;AAWD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,cAAc,CAAC,UAAU,CAAC,EACtC,UAAU,EAAE,MAAM,EAClB,eAAe,EAAE,OAAO,GACvB,QAAQ,CASV"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.32",
3
+ "version": "0.2.0-alpha.34",
4
4
  "description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -26,7 +26,7 @@ export function generateCompressModule(): string {
26
26
  // CompressionStream (Web API) on other runtimes. Brotli is left to CDNs/reverse
27
27
  // proxies — at streaming quality levels its ratio advantage is marginal.
28
28
  import { Readable } from 'node:stream';
29
- import { createGzip } from 'node:zlib';
29
+ import { createGzip, constants } from 'node:zlib';
30
30
 
31
31
  const COMPRESSIBLE_TYPES = new Set([
32
32
  'text/html', 'text/css', 'text/plain', 'text/xml', 'text/javascript',
@@ -82,7 +82,10 @@ function compressWithGzip(body) {
82
82
  // Node Readable → Readable.toWeb() → Web ReadableStream
83
83
  try {
84
84
  const nodeReadable = Readable.fromWeb(body);
85
- const gzip = createGzip();
85
+ // Z_SYNC_FLUSH ensures each chunk is flushed immediately so the browser
86
+ // receives the HTML shell before Suspense boundaries resolve. Without it,
87
+ // gzip buffers internally and breaks streaming.
88
+ const gzip = createGzip({ flush: constants.Z_SYNC_FLUSH });
86
89
  const compressed = nodeReadable.pipe(gzip);
87
90
  return Readable.toWeb(compressed);
88
91
  } catch {
@@ -159,7 +159,17 @@ setServerCallback(async (id: string, args: unknown[]) => {
159
159
  * Hydrates the server-rendered HTML with React, then initializes
160
160
  * client-side navigation for SPA transitions.
161
161
  */
162
- /** Read scroll position from window or scroll containers. */
162
+ /**
163
+ * Read the current scroll position.
164
+ *
165
+ * Checks window scroll first, then explicit `data-timber-scroll-restoration`
166
+ * containers. With segment tree merging, shared layouts are reconciled in
167
+ * place via `cloneElement` — React preserves their DOM and scroll state
168
+ * naturally. We don't need to auto-detect overflow containers; only
169
+ * explicitly marked containers are tracked.
170
+ *
171
+ * See design/19-client-navigation.md §"Overflow Scroll Containers".
172
+ */
163
173
  function getScrollY(): number {
164
174
  if (window.scrollY || document.documentElement.scrollTop || document.body.scrollTop) {
165
175
  return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop;
@@ -167,71 +177,17 @@ function getScrollY(): number {
167
177
  for (const el of document.querySelectorAll('[data-timber-scroll-restoration]')) {
168
178
  if ((el as HTMLElement).scrollTop > 0) return (el as HTMLElement).scrollTop;
169
179
  }
170
- // Auto-detect: if window isn't scrolled, check for overflow containers.
171
- // Common pattern: layouts use a scrollable div (overflow-y: auto/scroll)
172
- // inside a fixed-height parent (h-screen). In this case window.scrollY is
173
- // always 0 and the real scroll position lives on the overflow container.
174
- const container = findOverflowContainer();
175
- if (container && container.scrollTop > 0) return container.scrollTop;
176
180
  return 0;
177
181
  }
178
182
 
179
- /**
180
- * Find the primary overflow scroll container in the document.
181
- *
182
- * Walks direct children of body and their immediate children looking for
183
- * an element with overflow-y: auto|scroll that is actually scrollable
184
- * (scrollHeight > clientHeight). Returns the first match, or null.
185
- *
186
- * This heuristic covers the common layout patterns:
187
- * <body> → <root-layout> → <div class="overflow-y-auto">
188
- * <body> → <root-layout> → <main> → <nested-layout overflow-y-auto>
189
- *
190
- * We limit depth to 3 to avoid expensive full-tree traversals while still
191
- * reaching nested layout scroll containers (e.g., parallel route layouts
192
- * inside a root layout's <main> element).
193
- *
194
- * DIVERGENCE FROM NEXT.JS: Next.js's ScrollAndFocusHandler scrolls only
195
- * document.documentElement.scrollTop — it does NOT handle overflow containers.
196
- * Layouts using h-screen + overflow-y-auto have the same scroll bug in Next.js.
197
- * This heuristic is a deliberate improvement. The tradeoff is fragility: depth-3
198
- * traversal may miss deeply nested containers or match the wrong element.
199
- * See design/19-client-navigation.md §"Overflow Scroll Containers".
200
- */
201
- function findOverflowContainer(): HTMLElement | null {
202
- const candidates: HTMLElement[] = [];
203
- // Check body's descendants up to depth 3. Depth 3 covers the common case:
204
- // <body> → <root-layout-div> → <main> → <overflow-container>
205
- // React context providers (SegmentProvider, NavigationProvider) don't add
206
- // DOM elements, so depth 3 from body reaches nested layout scroll containers.
207
- for (const child of document.body.children) {
208
- candidates.push(child as HTMLElement);
209
- for (const grandchild of child.children) {
210
- candidates.push(grandchild as HTMLElement);
211
- for (const greatGrandchild of grandchild.children) {
212
- candidates.push(greatGrandchild as HTMLElement);
213
- }
214
- }
215
- }
216
- for (const el of candidates) {
217
- if (!(el instanceof HTMLElement)) continue;
218
- const style = getComputedStyle(el);
219
- const overflowY = style.overflowY;
220
- if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight) {
221
- return el;
222
- }
223
- }
224
- return null;
225
- }
226
-
227
183
  function bootstrap(runtimeConfig: typeof config): void {
228
184
  const _config = runtimeConfig;
229
185
 
230
- // Take manual control of scroll restoration. React's render() on the
231
- // document root resets scroll during DOM reconciliation, so the browser's
232
- // native scroll restoration (scrollRestoration = 'auto') doesn't work
233
- // the browser restores scroll, then React's commit resets it to 0.
234
- // We save/restore scroll positions explicitly in the history stack.
186
+ // Take manual control of scroll restoration. Even though segment tree
187
+ // merging preserves shared layout DOM via cloneElement (so React doesn't
188
+ // reset scroll on those elements), the root-level reactRoot.render() with
189
+ // a new element tree can still cause scroll resets on the document during
190
+ // reconciliation. Manual control ensures consistent behavior.
235
191
  window.history.scrollRestoration = 'manual';
236
192
 
237
193
  // Hydrate the React tree from the RSC payload.
@@ -247,6 +203,21 @@ function bootstrap(runtimeConfig: typeof config): void {
247
203
  // For subsequent navigations, it's fetched from the server.
248
204
  type FlightSegment = [isBootstrap: 0] | [isData: 1, data: string];
249
205
 
206
+ // On streaming pages, the browser entry module may load before the RSC
207
+ // payload scripts arrive. The <script id="_R_"> tag is in the shell (flushed
208
+ // on onShellReady), but the RSC bootstrap script `(self.__timber_f=...).push([0])`
209
+ // is injected by the flight injector AFTER Suspense resolution scripts.
210
+ // If the module import resolves before those scripts execute, __timber_f
211
+ // will be undefined.
212
+ //
213
+ // Fix: if __timber_f isn't available yet, pre-initialize it so the RSC
214
+ // bootstrap script's `(self.__timber_f=self.__timber_f||[]).push([0])` finds
215
+ // our array and pushes into it. This avoids a race condition that causes
216
+ // the browser entry to fall through to createRoot() (no hydration) on
217
+ // streaming pages.
218
+ if (!(self as unknown as Record<string, unknown>).__timber_f) {
219
+ (self as unknown as Record<string, FlightSegment[]>).__timber_f = [];
220
+ }
250
221
  const timberChunks = (self as unknown as Record<string, FlightSegment[]>).__timber_f;
251
222
 
252
223
  let _reactRoot: Root | null = null;
@@ -424,23 +395,20 @@ function bootstrap(runtimeConfig: typeof config): void {
424
395
  pushState: (data, unused, url) => window.history.pushState(data, unused, url),
425
396
  replaceState: (data, unused, url) => window.history.replaceState(data, unused, url),
426
397
  scrollTo: (x, y) => {
398
+ // Scroll the document viewport.
427
399
  window.scrollTo(x, y);
428
400
  document.documentElement.scrollTop = y;
429
401
  document.body.scrollTop = y;
430
- // Also scroll any element explicitly marked as a scroll container.
402
+ // Scroll any element explicitly marked as a scroll container.
403
+ // With segment tree merging, shared layouts (sidebars, nav bars)
404
+ // are reconciled in place via cloneElement — React preserves their
405
+ // DOM and scroll state naturally. We no longer auto-detect overflow
406
+ // containers, which previously found the wrong element (e.g.,
407
+ // scrolling a sidebar instead of the main content area).
408
+ // Use `data-timber-scroll-restoration` to opt in specific containers.
431
409
  for (const el of document.querySelectorAll('[data-timber-scroll-restoration]')) {
432
410
  (el as HTMLElement).scrollTop = y;
433
411
  }
434
- // Auto-detect overflow containers for layouts that scroll inside
435
- // a fixed-height wrapper (e.g., h-screen + overflow-y-auto).
436
- // In these layouts, window.scrollY is always 0 and the real scroll
437
- // lives on the overflow container. Without this, forward navigation
438
- // between pages that share a layout with parallel route slots won't
439
- // scroll to top — the router's window.scrollTo(0,0) is a no-op.
440
- const container = findOverflowContainer();
441
- if (container) {
442
- container.scrollTop = y;
443
- }
444
412
  },
445
413
  getCurrentUrl: () => window.location.pathname + window.location.search,
446
414
  getScrollY,
@@ -590,7 +558,6 @@ function bootstrap(runtimeConfig: typeof config): void {
590
558
  scrollTimer = setTimeout(() => {
591
559
  const state = window.history.state;
592
560
  if (state && typeof state === 'object') {
593
- // Use getScrollY to capture scroll from overflow containers too.
594
561
  window.history.replaceState({ ...state, scrollY: getScrollY() }, '');
595
562
  }
596
563
  }, 100);
@@ -160,15 +160,33 @@ export function compressResponse(request: Request, response: Response): Response
160
160
  });
161
161
  }
162
162
 
163
- // ─── Gzip (CompressionStream API) ────────────────────────────────────────
163
+ // ─── Gzip (node:zlib with Z_SYNC_FLUSH) ──────────────────────────────────
164
+ //
165
+ // Uses node:zlib's createGzip with Z_SYNC_FLUSH so each chunk is flushed
166
+ // to the output immediately. The Web Platform CompressionStream API buffers
167
+ // internally and does NOT flush per-chunk — this kills streaming because
168
+ // the browser doesn't receive the HTML shell until the gzip stream closes
169
+ // (i.e. after all Suspense boundaries resolve).
170
+ //
171
+ // Z_SYNC_FLUSH adds ~2–5% size overhead vs Z_NO_FLUSH but preserves
172
+ // correct streaming behavior: the shell renders instantly, Suspense
173
+ // fallbacks are visible immediately, and streamed content appears
174
+ // progressively.
175
+
176
+ import { createGzip, constants } from 'node:zlib';
177
+ import { Readable } from 'node:stream';
164
178
 
165
179
  /**
166
- * Compress a ReadableStream with gzip using the Web Platform CompressionStream API.
167
- * Available in Node 18+, Bun, and Deno — no npm dependency needed.
180
+ * Compress a ReadableStream with gzip, flushing each chunk immediately.
181
+ *
182
+ * Uses node:zlib's createGzip with Z_SYNC_FLUSH to ensure each HTML chunk
183
+ * (shell, Suspense resolution, RSC payload) is delivered to the browser
184
+ * as soon as it's available — preserving streaming semantics.
168
185
  */
169
186
  function compressWithGzip(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
170
- const compressionStream = new CompressionStream('gzip');
171
- // Cast needed: CompressionStream's WritableStream<BufferSource> type is wider
172
- // than ReadableStream's Uint8Array, but Uint8Array is a valid BufferSource.
173
- return body.pipeThrough(compressionStream as unknown as TransformStream<Uint8Array, Uint8Array>);
187
+ const gzip = createGzip({ flush: constants.Z_SYNC_FLUSH });
188
+ const nodeInput = Readable.fromWeb(body as import('stream/web').ReadableStream);
189
+ nodeInput.pipe(gzip);
190
+
191
+ return Readable.toWeb(gzip) as ReadableStream<Uint8Array>;
174
192
  }
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { Transform } from 'node:stream';
21
- import { createGzip } from 'node:zlib';
21
+ import { createGzip, constants } from 'node:zlib';
22
22
 
23
23
  // ─── Head Injection ──────────────────────────────────────────────────────────
24
24
 
@@ -124,18 +124,23 @@ export function createNodeFlightInjector(
124
124
  const rscReader = rscStream.getReader();
125
125
  const decoder = new TextDecoder('utf-8', { fatal: true });
126
126
 
127
- let pullPromise: Promise<void> | null = null;
128
127
  let donePulling = false;
129
128
  let pullError: unknown = null;
130
129
  let foundSuffix = false;
131
130
  let htmlStreamFinished = false;
132
- const pending: Buffer[] = [];
133
-
134
- // Emit bootstrap signal
135
- const bootstrap = `<script>(self.__timber_f=self.__timber_f||[]).push(${htmlEscapeJsonString(JSON.stringify([0]))})</script>`;
136
- pending.push(Buffer.from(bootstrap, 'utf-8'));
137
-
131
+ // Reference to the transform instance, set on first transform() call.
132
+ // Used by pullLoop to push RSC chunks directly to the output without
133
+ // waiting for HTML chunks to trigger drainPending.
134
+ let transformRef: Transform | null = null;
135
+
136
+ // pullLoop reads RSC chunks and pushes them directly to the transform
137
+ // output as <script> tags. This ensures RSC data is delivered to the
138
+ // browser as soon as it's available — not deferred until the next HTML
139
+ // chunk. Critical for streaming: the shell RSC payload must arrive
140
+ // with the shell HTML so hydration can start before Suspense resolves.
138
141
  async function pullLoop(): Promise<void> {
142
+ // Yield once so the first transform() call can set transformRef and
143
+ // emit the bootstrap signal before we start pushing data chunks.
139
144
  await new Promise<void>((r) => setImmediate(r));
140
145
  try {
141
146
  for (;;) {
@@ -146,7 +151,12 @@ export function createNodeFlightInjector(
146
151
  }
147
152
  const decoded = decoder.decode(value, { stream: true });
148
153
  const escaped = htmlEscapeJsonString(JSON.stringify([1, decoded]));
149
- pending.push(Buffer.from(`<script>self.__timber_f.push(${escaped})</script>`, 'utf-8'));
154
+ const scriptBuf = Buffer.from(`<script>self.__timber_f.push(${escaped})</script>`, 'utf-8');
155
+ // Push directly to the transform output — don't wait for an
156
+ // HTML chunk to trigger drainPending.
157
+ if (transformRef) {
158
+ transformRef.push(scriptBuf);
159
+ }
150
160
  if (!htmlStreamFinished) {
151
161
  await new Promise<void>((r) => setImmediate(r));
152
162
  }
@@ -157,25 +167,21 @@ export function createNodeFlightInjector(
157
167
  }
158
168
  }
159
169
 
160
- function drainPending(transform: Transform): void {
161
- while (pending.length > 0) {
162
- transform.push(pending.shift()!);
163
- }
164
- if (pullError) {
165
- transform.destroy(pullError instanceof Error ? pullError : new Error(String(pullError)));
166
- pullError = null;
167
- }
168
- }
169
-
170
170
  return new Transform({
171
171
  transform(chunk: Buffer, _encoding, callback) {
172
- if (!pullPromise) {
173
- pullPromise = pullLoop();
172
+ if (!transformRef) {
173
+ transformRef = this;
174
+ // Emit bootstrap signal with the first HTML chunk (the shell).
175
+ // This must arrive before any data chunks.
176
+ const bootstrap = `<script>(self.__timber_f=self.__timber_f||[]).push(${htmlEscapeJsonString(JSON.stringify([0]))})</script>`;
177
+ this.push(Buffer.from(bootstrap, 'utf-8'));
178
+ // Start the pull loop — it will push RSC data chunks directly
179
+ // to the transform output as they arrive from the RSC stream.
180
+ pullLoop();
174
181
  }
175
182
 
176
183
  if (foundSuffix) {
177
184
  this.push(chunk);
178
- if (pending.length > 0) drainPending(this);
179
185
  callback();
180
186
  return;
181
187
  }
@@ -187,7 +193,6 @@ export function createNodeFlightInjector(
187
193
  const before = text.slice(0, idx);
188
194
  const after = text.slice(idx + suffix.length);
189
195
  if (before) this.push(Buffer.from(before, 'utf-8'));
190
- if (pending.length > 0) drainPending(this);
191
196
  if (after) this.push(Buffer.from(after, 'utf-8'));
192
197
  } else {
193
198
  this.push(chunk);
@@ -198,7 +203,10 @@ export function createNodeFlightInjector(
198
203
  htmlStreamFinished = true;
199
204
 
200
205
  const finish = () => {
201
- drainPending(this);
206
+ if (pullError) {
207
+ this.destroy(pullError instanceof Error ? pullError : new Error(String(pullError)));
208
+ return;
209
+ }
202
210
  if (foundSuffix) {
203
211
  this.push(suffixBuf);
204
212
  }
@@ -209,10 +217,16 @@ export function createNodeFlightInjector(
209
217
  finish();
210
218
  return;
211
219
  }
212
- if (!pullPromise) {
213
- pullPromise = pullLoop();
214
- }
215
- pullPromise.then(finish);
220
+ // Wait for the RSC stream to finish before closing.
221
+ // pullLoop is already running and pushing directly.
222
+ const waitForPull = () => {
223
+ if (donePulling || pullError) {
224
+ finish();
225
+ } else {
226
+ setImmediate(waitForPull);
227
+ }
228
+ };
229
+ waitForPull();
216
230
  },
217
231
  });
218
232
  }
@@ -311,5 +325,9 @@ export function createNodeGzipCompressor(
311
325
  responseHeaders.set('vary', 'Accept-Encoding');
312
326
  }
313
327
 
314
- return createGzip();
328
+ // Z_SYNC_FLUSH ensures each chunk is flushed to the output immediately.
329
+ // Without it, gzip buffers internally and the browser doesn't receive
330
+ // the HTML shell until the gzip stream closes — breaking streaming.
331
+ // ~2–5% size overhead vs Z_NO_FLUSH but preserves correct streaming.
332
+ return createGzip({ flush: constants.Z_SYNC_FLUSH });
315
333
  }
@@ -68,6 +68,7 @@ import { renderRscStream } from './rsc-stream.js';
68
68
  import { renderSsrResponse } from './ssr-renderer.js';
69
69
  import { callSsr } from './ssr-bridge.js';
70
70
  import { isDebug, isDevMode, setDebugFromConfig } from '#/server/debug.js';
71
+ import { recordTiming } from '#/server/server-timing.js';
71
72
 
72
73
  /**
73
74
  * Resolve the Server-Timing mode from timber.config.ts.
@@ -325,6 +326,7 @@ async function renderRoute(
325
326
  // Build the React element tree — loads modules, runs access checks,
326
327
  // resolves metadata. DenySignal/RedirectSignal propagate for HTTP handling.
327
328
  let routeResult;
329
+ const _buildStart = performance.now();
328
330
  try {
329
331
  routeResult = await buildRouteElement(_req, match, interception, clientStateTree);
330
332
  } catch (error) {
@@ -364,6 +366,13 @@ async function renderRoute(
364
366
  throw error;
365
367
  }
366
368
 
369
+ const _buildEnd = performance.now();
370
+ recordTiming({
371
+ name: 'build',
372
+ dur: Math.round(_buildEnd - _buildStart),
373
+ desc: 'build element tree',
374
+ });
375
+
367
376
  const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } =
368
377
  routeResult;
369
378
 
@@ -416,7 +425,13 @@ async function renderRoute(
416
425
  }
417
426
 
418
427
  // Render to RSC Flight stream with signal tracking.
428
+ const _rscStart = performance.now();
419
429
  const { rscStream, signals } = renderRscStream(element, _req);
430
+ recordTiming({
431
+ name: 'rsc-init',
432
+ dur: Math.round(performance.now() - _rscStart),
433
+ desc: 'RSC stream init',
434
+ });
420
435
 
421
436
  // Synchronous redirect — redirect() in access.ts or a non-async component
422
437
  // throws during renderToReadableStream creation. Return HTTP redirect.
@@ -31,6 +31,7 @@ import {
31
31
  import { renderErrorPage } from './error-renderer.js';
32
32
  import { callSsr } from './ssr-bridge.js';
33
33
  import type { RenderSignals } from './rsc-stream.js';
34
+ import { recordTiming } from '#/server/server-timing.js';
34
35
 
35
36
  interface SsrRenderOptions {
36
37
  req: Request;
@@ -156,6 +157,21 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
156
157
  try {
157
158
  const ssrResponse = await callSsr(ssrStream, navContext);
158
159
 
160
+ // Record SSR sub-phase timings for Server-Timing header (detailed mode).
161
+ // These are populated by handleSsr() in the SSR environment and passed
162
+ // back via navContext._ssrTimings across the RSC→SSR boundary.
163
+ if (navContext._ssrTimings) {
164
+ const t = navContext._ssrTimings;
165
+ recordTiming({ name: 'ssr-decode', dur: t.decodeMs, desc: 'RSC Flight decode' });
166
+ recordTiming({ name: 'ssr-shell', dur: t.shellMs, desc: 'Fizz onShellReady' });
167
+ recordTiming({ name: 'ssr-pipeline', dur: t.pipelineMs, desc: 'stream transforms' });
168
+ recordTiming({
169
+ name: 'ssr-total',
170
+ dur: t.totalMs,
171
+ desc: t.nodeStreams ? 'SSR (Node streams)' : 'SSR (Web Streams)',
172
+ });
173
+ }
174
+
159
175
  // Signal promotion: check if any signals were captured during rendering
160
176
  // inside Suspense boundaries. If no signals are present yet, yield one
161
177
  // microtask so async component rejections propagate to the RSC onError
@@ -126,6 +126,29 @@ export interface NavContext {
126
126
  * to a page-level deny. See LOCAL-298.
127
127
  */
128
128
  _denyHandledByBoundary?: boolean;
129
+
130
+ /**
131
+ * Mutable: SSR timing data populated by handleSsr().
132
+ * Read by the RSC entry to record sub-phase Server-Timing entries
133
+ * when `serverTiming: 'detailed'` is configured.
134
+ *
135
+ * This bridges the RSC→SSR environment boundary: the SSR entry populates
136
+ * these fields, the RSC entry reads them after callSsr() returns.
137
+ */
138
+ _ssrTimings?: {
139
+ /** Time to decode RSC stream (createFromReadableStream/createFromNodeStream) */
140
+ decodeMs: number;
141
+ /** Time for Fizz to render the shell (onShellReady) */
142
+ shellMs: number;
143
+ /** Time for pipe() to flush shell bytes to the output stream */
144
+ pipeMs: number;
145
+ /** Time to set up Node.js Transform pipeline / Web Stream transforms */
146
+ pipelineMs: number;
147
+ /** Total SSR time (decode → response ready) */
148
+ totalMs: number;
149
+ /** Whether the Node.js native stream path was used */
150
+ nodeStreams: boolean;
151
+ };
129
152
  }
130
153
 
131
154
  /**
@@ -182,11 +205,8 @@ export async function handleSsr(
182
205
  // createFromReadableStream resolves client component references
183
206
  // (from "use client" modules) using the SSR environment's module
184
207
  // map, importing the actual components for server-side rendering.
185
- const _s0 = performance.now();
186
- // eslint-disable-next-line no-console
187
- console.log(
188
- `[diag] nodeImports=${!!_nodeStreamImports} nodeStreamDecode=${hasNodeStreamDecode} rscStream=${rscStream?.constructor?.name}`
189
- );
208
+ const _ssrStart = performance.now();
209
+
190
210
  // Decode the RSC stream into a React element tree.
191
211
  // On Node.js: convert Web ReadableStream → Node Readable → createFromNodeStream
192
212
  // (eliminates Promise-per-chunk overhead from Web Streams reader)
@@ -200,21 +220,13 @@ export async function handleSsr(
200
220
  } else {
201
221
  element = createFromReadableStream(rscStream) as React.ReactNode;
202
222
  }
203
- const _s1 = performance.now();
223
+ const _decodeEnd = performance.now();
204
224
 
205
225
  // Wrap with a server-safe nuqs adapter so that 'use client' components
206
226
  // that call nuqs hooks (useQueryStates, useQueryState) can SSR correctly.
207
- // The client-side TimberNuqsAdapter (injected by browser-entry.ts) takes
208
- // over after hydration. This provider supplies the request's search params
209
- // as a static snapshot so nuqs renders the right initial values on the server.
210
227
  const wrappedElement = withNuqsSsrAdapter(navContext.searchParams, element);
211
- const _s2 = performance.now();
212
228
 
213
229
  // Render to HTML stream (waits for onShellReady).
214
- // Pass bootstrapScriptContent so React injects a non-deferred <script>
215
- // in the shell HTML. This executes immediately during parsing — even
216
- // while Suspense boundaries are still streaming — triggering module
217
- // loading via dynamic import() so hydration can start early.
218
230
  //
219
231
  // Two paths based on platform:
220
232
  // - Node.js: renderToPipeableStream → Node Transform pipeline → Readable.toWeb() → Response
@@ -231,7 +243,6 @@ export async function handleSsr(
231
243
  PassThrough,
232
244
  } = _nodeStreamImports;
233
245
 
234
- const _s3 = performance.now();
235
246
  let nodeHtmlStream: import('node:stream').Readable;
236
247
  try {
237
248
  nodeHtmlStream = await renderSsrNodeStream(wrappedElement, {
@@ -249,29 +260,27 @@ export async function handleSsr(
249
260
  renderError
250
261
  );
251
262
  }
263
+ const _renderEnd = performance.now();
252
264
 
253
- // Build Node.js Transform pipeline: errorHandler → headInjector → flightInjector → gzip
254
265
  const errorHandler = createNodeErrorHandler(navContext.signal);
255
266
  const headInjector = createNodeHeadInjector(navContext.headHtml);
256
267
  const flightInjector = createNodeFlightInjector(navContext.rscStream);
257
268
 
258
- // Pipe through the chain. pipeline() handles backpressure and error propagation.
259
- // The last stream in the chain is the output — convert to Web ReadableStream
260
- // only at the Response boundary.
261
- // Note: gzip compression is still handled by compressResponse() in the Nitro
262
- // entry via Web Streams CompressionStream. Moving it into this Node.js pipeline
263
- // requires the request headers (Accept-Encoding) which NavContext doesn't carry.
264
- // TODO: pass request headers through NavContext to enable inline Node.js gzip.
265
269
  const output = new PassThrough();
266
270
  pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
267
271
  // Pipeline errors are handled by errorHandler transform
268
272
  });
273
+ const _pipelineEnd = performance.now();
269
274
 
270
- const _s4 = performance.now();
271
- // eslint-disable-next-line no-console
272
- console.log(
273
- `[ssr-perf] decode=${(_s1 - _s0).toFixed(1)}ms nuqs=${(_s2 - _s1).toFixed(1)}ms imports=${(_s3 - _s2).toFixed(1)}ms renderToPipeable=${(_s4 - _s3).toFixed(1)}ms pipeline=${(performance.now() - _s4).toFixed(1)}ms total=${(performance.now() - _s0).toFixed(1)}ms`
274
- );
275
+ // Record SSR sub-timings for Server-Timing header (detailed mode).
276
+ navContext._ssrTimings = {
277
+ decodeMs: Math.round(_decodeEnd - _ssrStart),
278
+ shellMs: Math.round(_renderEnd - _decodeEnd),
279
+ pipeMs: 0, // pipe() timing is inside renderSsrNodeStream
280
+ pipelineMs: Math.round(_pipelineEnd - _renderEnd),
281
+ totalMs: Math.round(_pipelineEnd - _ssrStart),
282
+ nodeStreams: true,
283
+ };
275
284
 
276
285
  const webStream = nodeReadableToWeb(output);
277
286
  return buildSsrResponse(webStream, navContext.statusCode, navContext.responseHeaders);
@@ -296,10 +305,22 @@ export async function handleSsr(
296
305
  );
297
306
  }
298
307
 
308
+ const _renderEnd = performance.now();
309
+
299
310
  // Inject metadata into <head>, then interleave RSC payload chunks
300
311
  // into the body as they arrive from the tee'd RSC stream.
301
312
  let outputStream = injectHead(htmlStream, navContext.headHtml);
302
313
  outputStream = injectRscPayload(outputStream, navContext.rscStream);
314
+ const _pipelineEnd = performance.now();
315
+
316
+ navContext._ssrTimings = {
317
+ decodeMs: Math.round(_decodeEnd - _ssrStart),
318
+ shellMs: Math.round(_renderEnd - _decodeEnd),
319
+ pipeMs: 0,
320
+ pipelineMs: Math.round(_pipelineEnd - _renderEnd),
321
+ totalMs: Math.round(_pipelineEnd - _ssrStart),
322
+ nodeStreams: false,
323
+ };
303
324
 
304
325
  // Build and return the Response.
305
326
  return buildSsrResponse(outputStream, navContext.statusCode, navContext.responseHeaders);