@timber-js/app 0.1.50 → 0.1.52

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":"compress.d.ts","sourceRoot":"","sources":["../../src/server/compress.ts"],"names":[],"mappings":"AAYA;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,aAc7B,CAAC;AASH;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CASvE;AAID;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAmB1D;AAID;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,GAAG,QAAQ,CAiC/E"}
1
+ {"version":3,"file":"compress.d.ts","sourceRoot":"","sources":["../../src/server/compress.ts"],"names":[],"mappings":"AAYA;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,aAc7B,CAAC;AASH;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4BvE;AAID;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAmB1D;AAID;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,GAAG,QAAQ,CAiC/E"}
@@ -39,5 +39,5 @@ export declare function parseClientStateTree(req: Request): Set<string> | null;
39
39
  * @param isLeaf - Whether this is the leaf segment (page segment)
40
40
  * @param clientSegments - Set of paths from X-Timber-State-Tree, or null
41
41
  */
42
- export declare function shouldSkipSegment(urlPath: string, layoutComponent: ((...args: unknown[]) => unknown) | undefined, isLeaf: boolean, clientSegments: Set<string> | null): boolean;
42
+ export declare function shouldSkipSegment(_urlPath: string, _layoutComponent: ((...args: unknown[]) => unknown) | undefined, _isLeaf: boolean, _clientSegments: Set<string> | null): boolean;
43
43
  //# sourceMappingURL=state-tree-diff.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAarE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,eAAe,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,SAAS,EAC9D,MAAM,EAAE,OAAO,EACf,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACjC,OAAO,CAeT"}
1
+ {"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAarE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,gBAAgB,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,SAAS,EAC/D,OAAO,EAAE,OAAO,EAChB,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAClC,OAAO,CAgBT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
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",
@@ -37,12 +37,25 @@ const NO_COMPRESS_STATUSES = new Set([204, 304]);
37
37
 
38
38
  function negotiateEncoding(acceptEncoding) {
39
39
  if (!acceptEncoding) return null;
40
- const tokens = acceptEncoding.split(',').map(s => s.split(';')[0].trim().toLowerCase());
40
+ // Parse tokens with quality values. Per RFC 9110 §12.5.3, q=0 means
41
+ // "not acceptable" — the client explicitly rejects that encoding.
41
42
  // Brotli (br) is intentionally not handled at the application level.
42
- // At streaming-friendly quality levels (0-4), brotli's ratio advantage over
43
- // gzip is marginal, and node:zlib's brotli transform buffers output internally.
44
- // CDNs/reverse proxies apply brotli at higher quality levels on cached responses.
45
- if (tokens.includes('gzip')) return 'gzip';
43
+ const parts = acceptEncoding.split(',');
44
+ for (const part of parts) {
45
+ const [token, ...params] = part.split(';');
46
+ const name = token.trim().toLowerCase();
47
+ if (name !== 'gzip') continue;
48
+ let qValue = 1;
49
+ for (const param of params) {
50
+ const trimmed = param.trim().toLowerCase();
51
+ if (trimmed.startsWith('q=')) {
52
+ qValue = parseFloat(trimmed.slice(2));
53
+ if (Number.isNaN(qValue)) qValue = 1;
54
+ break;
55
+ }
56
+ }
57
+ if (qValue > 0) return 'gzip';
58
+ }
46
59
  return null;
47
60
  }
48
61
 
@@ -341,7 +341,7 @@ export function generateNitroEntry(
341
341
  return `// Generated by @timber-js/app/adapters/nitro
342
342
  // Do not edit — this file is regenerated on each build.
343
343
 
344
- ${manifestImport}import { defineEventHandler } from 'h3'
344
+ ${manifestImport}import { defineEventHandler } from 'nitro/h3'
345
345
  import handler, { runWithEarlyHintsSender${waitUntilImport} } from '${serverEntryRelative}'
346
346
  import { compressResponse } from './_compress.mjs'
347
347
 
@@ -52,6 +52,7 @@ import {
52
52
  import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.js';
53
53
  import { handleLinkClick, handleLinkHover } from './browser-links.js';
54
54
  import { TransitionRoot, transitionRender, navigateTransition } from './transition-root.js';
55
+ import { isStaleClientReference, triggerStaleReload, clearStaleReloadFlag } from './stale-reload.js';
55
56
 
56
57
  // ─── Server Action Dispatch ──────────────────────────────────────
57
58
 
@@ -94,7 +95,17 @@ setServerCallback(async (id: string, args: unknown[]) => {
94
95
  return res;
95
96
  });
96
97
 
97
- const decoded = await createFromFetch(response);
98
+ let decoded: unknown;
99
+ try {
100
+ decoded = await createFromFetch(response);
101
+ } catch (error) {
102
+ if (isStaleClientReference(error)) {
103
+ triggerStaleReload();
104
+ // Return a never-resolving promise to prevent further processing
105
+ return new Promise(() => {});
106
+ }
107
+ throw error;
108
+ }
98
109
 
99
110
  // Handle redirect — server encoded the redirect location in the RSC stream
100
111
  // instead of returning HTTP 302. Perform a client-side SPA navigation.
@@ -169,25 +180,33 @@ function getScrollY(): number {
169
180
  * (scrollHeight > clientHeight). Returns the first match, or null.
170
181
  *
171
182
  * This heuristic covers the common layout patterns:
172
- * <body> → <html-wrapper> → <div class="overflow-y-auto">
173
- * <body> → <main class="overflow-y-auto">
183
+ * <body> → <root-layout> → <div class="overflow-y-auto">
184
+ * <body> → <root-layout> → <main> → <nested-layout overflow-y-auto>
174
185
  *
175
- * We limit depth to avoid expensive full-tree traversals.
186
+ * We limit depth to 3 to avoid expensive full-tree traversals while still
187
+ * reaching nested layout scroll containers (e.g., parallel route layouts
188
+ * inside a root layout's <main> element).
176
189
  *
177
190
  * DIVERGENCE FROM NEXT.JS: Next.js's ScrollAndFocusHandler scrolls only
178
191
  * document.documentElement.scrollTop — it does NOT handle overflow containers.
179
192
  * Layouts using h-screen + overflow-y-auto have the same scroll bug in Next.js.
180
- * This heuristic is a deliberate improvement. The tradeoff is fragility: depth-2
193
+ * This heuristic is a deliberate improvement. The tradeoff is fragility: depth-3
181
194
  * traversal may miss deeply nested containers or match the wrong element.
182
195
  * See design/19-client-navigation.md §"Overflow Scroll Containers".
183
196
  */
184
197
  function findOverflowContainer(): HTMLElement | null {
185
198
  const candidates: HTMLElement[] = [];
186
- // Check body's direct children and their children (depth 2)
199
+ // Check body's descendants up to depth 3. Depth 3 covers the common case:
200
+ // <body> → <root-layout-div> → <main> → <overflow-container>
201
+ // React context providers (SegmentProvider, NavigationProvider) don't add
202
+ // DOM elements, so depth 3 from body reaches nested layout scroll containers.
187
203
  for (const child of document.body.children) {
188
204
  candidates.push(child as HTMLElement);
189
205
  for (const grandchild of child.children) {
190
206
  candidates.push(grandchild as HTMLElement);
207
+ for (const greatGrandchild of grandchild.children) {
208
+ candidates.push(greatGrandchild as HTMLElement);
209
+ }
191
210
  }
192
211
  }
193
212
  for (const el of candidates) {
@@ -425,8 +444,24 @@ function bootstrap(runtimeConfig: typeof config): void {
425
444
  // Decode RSC Flight stream using createFromFetch.
426
445
  // createFromFetch takes a Promise<Response> and progressively
427
446
  // parses the RSC stream as chunks arrive.
428
- decodeRsc: (fetchPromise: Promise<Response>) => {
429
- return createFromFetch(fetchPromise);
447
+ //
448
+ // Wrapped with stale client reference detection: if the server
449
+ // has been redeployed with new bundles, the RSC payload may
450
+ // reference module IDs that don't exist in the old client bundle.
451
+ // We catch "Could not find the module" errors and trigger a full
452
+ // page reload so the browser fetches the new bundle.
453
+ decodeRsc: async (fetchPromise: Promise<Response>) => {
454
+ try {
455
+ return await createFromFetch(fetchPromise);
456
+ } catch (error) {
457
+ if (isStaleClientReference(error)) {
458
+ triggerStaleReload();
459
+ // Return a never-resolving promise to prevent further processing
460
+ // while the page is reloading.
461
+ return new Promise(() => {});
462
+ }
463
+ throw error;
464
+ }
430
465
  },
431
466
 
432
467
  // Render decoded RSC tree via TransitionRoot's state-based mechanism.
@@ -614,6 +649,24 @@ function bootstrap(runtimeConfig: typeof config): void {
614
649
 
615
650
  bootstrap(config);
616
651
 
652
+ // Clear the stale reload flag on successful bootstrap. If the page
653
+ // loaded and bootstrapped without hitting a stale reference error,
654
+ // the loop guard should reset so the next stale error gets a fresh
655
+ // reload attempt.
656
+ clearStaleReloadFlag();
657
+
658
+ // Global error handler for stale client reference errors during hydration.
659
+ // The initial RSC payload is decoded lazily by React via createFromReadableStream.
660
+ // If the payload references a module ID from a newer deployment, the error
661
+ // surfaces as an unhandled rejection during React's render/hydration cycle.
662
+ // This handler catches those errors and triggers a full page reload.
663
+ window.addEventListener('unhandledrejection', (event) => {
664
+ if (isStaleClientReference(event.reason)) {
665
+ event.preventDefault();
666
+ triggerStaleReload();
667
+ }
668
+ });
669
+
617
670
  // Signal that the client runtime has been initialized.
618
671
  // Used by E2E tests to wait for hydration before interacting.
619
672
  // We append a <meta name="timber-ready"> tag rather than setting a
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Stale Client Reference Reload
3
+ *
4
+ * When a new deployment ships updated bundles, clients running stale
5
+ * JavaScript may encounter "Could not find the module" errors during
6
+ * RSC Flight stream decoding. This happens because the RSC payload
7
+ * references module IDs from the new bundle that don't exist in the
8
+ * old client bundle.
9
+ *
10
+ * This module detects these specific errors and triggers a full page
11
+ * reload so the browser fetches the new bundle. A sessionStorage flag
12
+ * guards against infinite reload loops.
13
+ *
14
+ * See: LOCAL-332
15
+ */
16
+
17
+ const RELOAD_FLAG_KEY = '__timber_stale_reload';
18
+
19
+ /**
20
+ * Check if an error is a stale client reference error from React's
21
+ * Flight client. These errors have the message pattern:
22
+ * "Could not find the module \"<id>\""
23
+ *
24
+ * This is thrown by react-server-dom-webpack's client when the RSC
25
+ * payload references a module ID that doesn't exist in the client's
26
+ * module map — typically because the server has been redeployed with
27
+ * new bundle hashes while the client is still running old JavaScript.
28
+ */
29
+ export function isStaleClientReference(error: unknown): boolean {
30
+ if (!(error instanceof Error)) return false;
31
+ const msg = error.message;
32
+ return msg.includes('Could not find the module');
33
+ }
34
+
35
+ /**
36
+ * Trigger a full page reload to pick up new bundles.
37
+ *
38
+ * Sets a sessionStorage flag before reloading. If the flag is already
39
+ * set (meaning we already reloaded once for this reason), we don't
40
+ * reload again — this prevents infinite reload loops if the error
41
+ * persists after reload (e.g., a genuine bug rather than a stale bundle).
42
+ *
43
+ * @returns true if a reload was triggered, false if suppressed (loop guard)
44
+ */
45
+ export function triggerStaleReload(): boolean {
46
+ try {
47
+ // Check if we already reloaded — prevent infinite loop
48
+ if (sessionStorage.getItem(RELOAD_FLAG_KEY)) {
49
+ console.warn(
50
+ '[timber] Stale client reference detected again after reload. ' +
51
+ 'Not reloading to prevent infinite loop. ' +
52
+ 'This may indicate a deployment issue — try a hard refresh.'
53
+ );
54
+ return false;
55
+ }
56
+
57
+ // Set the flag before reloading
58
+ sessionStorage.setItem(RELOAD_FLAG_KEY, '1');
59
+
60
+ console.warn(
61
+ '[timber] Stale client reference detected — the server has been ' +
62
+ 'redeployed with new bundles. Reloading to pick up the new version.'
63
+ );
64
+
65
+ window.location.reload();
66
+ return true;
67
+ } catch {
68
+ // sessionStorage may be unavailable (private browsing, storage full, etc.)
69
+ // Fall back to reloading without loop protection
70
+ console.warn(
71
+ '[timber] Stale client reference detected. Reloading page.'
72
+ );
73
+ window.location.reload();
74
+ return true;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Clear the stale reload flag. Called on successful bootstrap to reset
80
+ * the loop guard — if the page loaded successfully, the next stale
81
+ * reference error should trigger a fresh reload attempt.
82
+ */
83
+ export function clearStaleReloadFlag(): void {
84
+ try {
85
+ sessionStorage.removeItem(RELOAD_FLAG_KEY);
86
+ } catch {
87
+ // sessionStorage unavailable — nothing to clear
88
+ }
89
+ }
@@ -55,11 +55,30 @@ const NO_COMPRESS_STATUSES = new Set([204, 304]);
55
55
  export function negotiateEncoding(acceptEncoding: string): 'gzip' | null {
56
56
  if (!acceptEncoding) return null;
57
57
 
58
- // Parse tokens from the Accept-Encoding header (ignore quality values).
59
- // e.g. "gzip;q=1.0, br;q=0.8, deflate" ['gzip', 'br', 'deflate']
60
- const tokens = acceptEncoding.split(',').map((s) => s.split(';')[0].trim().toLowerCase());
58
+ // Parse tokens with quality values from the Accept-Encoding header.
59
+ // Per RFC 9110 §12.5.3, q=0 means "not acceptable" the client explicitly
60
+ // rejects that encoding. Tokens without a q-value default to q=1.
61
+ // e.g. "gzip;q=1.0, br;q=0" → gzip is acceptable, br is rejected.
62
+ const parts = acceptEncoding.split(',');
63
+ for (const part of parts) {
64
+ const [token, ...params] = part.split(';');
65
+ const name = token.trim().toLowerCase();
66
+ if (name !== 'gzip') continue;
67
+
68
+ // Check for q=0 (explicitly disabled)
69
+ let qValue = 1; // default quality is 1
70
+ for (const param of params) {
71
+ const trimmed = param.trim().toLowerCase();
72
+ if (trimmed.startsWith('q=')) {
73
+ qValue = parseFloat(trimmed.slice(2));
74
+ if (Number.isNaN(qValue)) qValue = 1;
75
+ break;
76
+ }
77
+ }
78
+
79
+ if (qValue > 0) return 'gzip';
80
+ }
61
81
 
62
- if (tokens.includes('gzip')) return 'gzip';
63
82
  return null;
64
83
  }
65
84
 
@@ -55,23 +55,24 @@ export function parseClientStateTree(req: Request): Set<string> | null {
55
55
  * @param clientSegments - Set of paths from X-Timber-State-Tree, or null
56
56
  */
57
57
  export function shouldSkipSegment(
58
- urlPath: string,
59
- layoutComponent: ((...args: unknown[]) => unknown) | undefined,
60
- isLeaf: boolean,
61
- clientSegments: Set<string> | null
58
+ _urlPath: string,
59
+ _layoutComponent: ((...args: unknown[]) => unknown) | undefined,
60
+ _isLeaf: boolean,
61
+ _clientSegments: Set<string> | null
62
62
  ): boolean {
63
- // No state tree full render (initial load, refresh, etc.)
64
- if (!clientSegments) return false;
65
-
66
- // Leaf segments (pages) are never skipped
67
- if (isLeaf) return false;
68
-
69
- // No layout → nothing to skip
70
- if (!layoutComponent) return false;
71
-
72
- // Async layouts always re-render (they may depend on request context)
73
- if (layoutComponent.constructor?.name === 'AsyncFunction') return false;
74
-
75
- // Skip if the client already has this segment cached
76
- return clientSegments.has(urlPath);
63
+ // DISABLED: Server-side segment skipping is not safe without client-side
64
+ // tree merging. When the server omits a layout from the RSC payload, the
65
+ // client receives a tree with a different structure (no SegmentProvider,
66
+ // no layout wrapper). React sees a different component tree shape and
67
+ // re-mounts everything — destroying DOM state (input values, focus,
68
+ // scroll position) in layouts that should have been preserved.
69
+ //
70
+ // The correct fix requires client-side tree merging: the router must
71
+ // splice the partial RSC response into the existing cached tree, only
72
+ // replacing changed segments. Until that's implemented, always render
73
+ // the full tree. RSC naturally handles this efficiently — client
74
+ // components are sent as references, not re-serialized.
75
+ //
76
+ // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
77
+ return false;
77
78
  }