@timber-js/app 0.2.0-alpha.141 → 0.2.0-alpha.143

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.141",
3
+ "version": "0.2.0-alpha.143",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -114,11 +114,6 @@
114
114
  "publishConfig": {
115
115
  "access": "public"
116
116
  },
117
- "scripts": {
118
- "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
119
- "typecheck": "tsgo --noEmit",
120
- "prepublishOnly": "pnpm run build"
121
- },
122
117
  "dependencies": {
123
118
  "@opentelemetry/api": "^1.9.1",
124
119
  "@opentelemetry/context-async-hooks": "^2.7.1",
@@ -132,10 +127,11 @@
132
127
  "@content-collections/vite": "^0.2.0",
133
128
  "@mdx-js/rollup": "^3.0.0",
134
129
  "@vitejs/plugin-react": "^6.0.0",
135
- "@vitejs/plugin-rsc": ">=0.5.21",
130
+ "@vitejs/plugin-rsc": ">=0.5.27",
136
131
  "nuqs": "^2.0.0",
137
- "react": "19.3.0-canary-d5736f09-20260507",
138
- "react-dom": "19.3.0-canary-d5736f09-20260507",
132
+ "react": ">=19.3.0-canary-43bcbf80-20260603",
133
+ "react-dom": ">=19.3.0-canary-43bcbf80-20260603",
134
+ "react-server-dom-webpack": ">=19.3.0-canary-43bcbf80-20260603",
139
135
  "vite": "^8.0.13",
140
136
  "wrangler": "^4.0.0",
141
137
  "zod": "^3.22.0 || ^4.0.0"
@@ -162,5 +158,9 @@
162
158
  },
163
159
  "engines": {
164
160
  "node": ">=22.12.0"
161
+ },
162
+ "scripts": {
163
+ "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
164
+ "typecheck": "tsgo --noEmit"
165
165
  }
166
- }
166
+ }
package/src/cli.ts CHANGED
File without changes
@@ -17,7 +17,6 @@
17
17
  import { setServerCallback, encodeReply, createFromFetch } from '../../rsc-runtime/browser.js';
18
18
  import { getRouter, getRouterOrNull } from '#client-internal';
19
19
  import { setHardNavigating } from '../navigation-root.js';
20
- import { isStaleClientReference, triggerStaleReload } from '../stale-reload.js';
21
20
  import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../rsc-fetch.js';
22
21
 
23
22
  export function setupServerActions(): void {
@@ -59,17 +58,7 @@ export function setupServerActions(): void {
59
58
  return res;
60
59
  });
61
60
 
62
- let decoded: unknown;
63
- try {
64
- decoded = await createFromFetch(response);
65
- } catch (error) {
66
- if (isStaleClientReference(error)) {
67
- triggerStaleReload();
68
- // Return a never-resolving promise to prevent further processing
69
- return new Promise(() => {});
70
- }
71
- throw error;
72
- }
61
+ const decoded = await createFromFetch(response);
73
62
 
74
63
  // Handle redirect — server encoded the redirect location in the RSC stream
75
64
  // instead of returning HTTP 302. Perform a client-side SPA navigation.
@@ -21,8 +21,7 @@
21
21
  * 5. hydrateApp() — hydrateRoot or deferred createRoot
22
22
  * 6. setupPostHydration() — history stack, popstate, scroll
23
23
  * 7. setupHmr() — dev-only HMR wiring
24
- * 8. stale reload handlers — global error listeners
25
- * 9. timber-ready signal — E2E test readiness indicator
24
+ * 8. timber-ready signal — E2E test readiness indicator
26
25
  *
27
26
  * Design docs: 18-build-system.md §"Entry Files", 19-client-navigation.md
28
27
  */
@@ -42,12 +41,6 @@ import { setLinkCodecs } from '../../params/codec-registry.js';
42
41
  setLinkCodecs(schemaCodecs);
43
42
 
44
43
  import { setClientDeploymentId } from '../rsc-fetch.js';
45
- import {
46
- isStaleClientReference,
47
- isChunkLoadError,
48
- triggerStaleReload,
49
- clearStaleReloadFlag,
50
- } from '../stale-reload.js';
51
44
 
52
45
  import { setupServerActions } from './action-dispatch.js';
53
46
  import { createRscPayloadStream } from './rsc-stream.js';
@@ -104,40 +97,7 @@ function bootstrap(runtimeConfig: typeof config): void {
104
97
 
105
98
  bootstrap(config);
106
99
 
107
- // ─── 8. Stale Reload Handlers ───────────────────────────────────
108
-
109
- // Clear the stale reload flag on successful bootstrap. If the page
110
- // loaded and bootstrapped without hitting a stale reference error,
111
- // the loop guard should reset so the next stale error gets a fresh
112
- // reload attempt.
113
- clearStaleReloadFlag();
114
-
115
- // Global error handler for stale client reference errors during hydration.
116
- // The initial RSC payload is decoded lazily by React via createFromReadableStream.
117
- // If the payload references a module ID from a newer deployment, the error
118
- // surfaces as an unhandled rejection during React's render/hydration cycle.
119
- // This handler catches those errors and triggers a full page reload.
120
- //
121
- // Also catches chunk load failures (dynamic import of missing assets after
122
- // a deployment) — these surface as "Failed to fetch dynamically imported module"
123
- // or "Loading chunk <name> failed" errors. See TIM-446.
124
- window.addEventListener('unhandledrejection', (event) => {
125
- if (isStaleClientReference(event.reason) || isChunkLoadError(event.reason)) {
126
- event.preventDefault();
127
- triggerStaleReload();
128
- }
129
- });
130
-
131
- // Also catch synchronous errors from chunk loads (some browsers throw
132
- // TypeError synchronously instead of via unhandled rejection).
133
- window.addEventListener('error', (event) => {
134
- if (isChunkLoadError(event.error)) {
135
- event.preventDefault();
136
- triggerStaleReload();
137
- }
138
- });
139
-
140
- // ─── 9. Ready Signal ────────────────────────────────────────────
100
+ // ─── 8. Ready Signal ────────────────────────────────────────────
141
101
 
142
102
  // Signal that the client runtime has been initialized.
143
103
  // Used by E2E tests to wait for hydration before interacting.
@@ -17,7 +17,6 @@ import { TimberNuqsAdapter } from '../nuqs-adapter.js';
17
17
  import { NavigationProvider } from '../navigation-context.js';
18
18
  import { transitionRender, navigateTransition } from '../navigation-root.js';
19
19
  import { SegmentUpdateContext, EMPTY_SEGMENT_UPDATES } from '../segment-update-context.js';
20
- import { isStaleClientReference, triggerStaleReload } from '../stale-reload.js';
21
20
  import {
22
21
  hasNavigationApi,
23
22
  setupNavigationApi,
@@ -79,30 +78,8 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
79
78
  getCurrentUrl: () => window.location.pathname + window.location.search,
80
79
  getScrollY,
81
80
 
82
- // Decode RSC Flight stream using createFromFetch.
83
- // createFromFetch takes a Promise<Response> and progressively
84
- // parses the RSC stream as chunks arrive.
85
- //
86
- // Wrapped with stale client reference detection: if the server
87
- // has been redeployed with new bundles, the RSC payload may
88
- // reference module IDs that don't exist in the old client bundle.
89
- // We catch "Could not find the module" errors and trigger a full
90
- // page reload so the browser fetches the new bundle.
91
- // Pass the fetch promise directly to createFromFetch for streaming.
92
- // React progressively renders chunks via Suspense as they arrive.
93
- // The transition stays open via decodePromise (awaited inside
94
- // startTransition) so useOptimistic pending state persists for
95
- // the full stream duration.
96
81
  decodeRsc: (fetchPromise: Promise<Response>) => {
97
- try {
98
- return createFromFetch(fetchPromise);
99
- } catch (error) {
100
- if (isStaleClientReference(error)) {
101
- triggerStaleReload();
102
- return new Promise(() => {});
103
- }
104
- throw error;
105
- }
82
+ return createFromFetch(fetchPromise);
106
83
  },
107
84
 
108
85
  // Render decoded RSC tree via NavigationRoot's state-based mechanism.
@@ -19,7 +19,6 @@ import {
19
19
  VersionSkewError,
20
20
  } from './rsc-fetch.js';
21
21
  import { setHardNavigating } from './navigation-root.js';
22
- import { isStaleClientReference, triggerStaleReload } from './stale-reload.js';
23
22
  import type { FetchResult } from './rsc-fetch.js';
24
23
 
25
24
  // ─── Types ───────────────────────────────────────────────────────
@@ -565,17 +564,11 @@ export function createRouter(deps: RouterDeps): RouterInstance {
565
564
  // scroll during DOM reconciliation, so all scroll must be actively managed.
566
565
  restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
567
566
  } catch (error) {
568
- // Version skew — server has been redeployed. Trigger full page reload
567
+ // Version skew — server has been redeployed. Full page reload
569
568
  // so the browser fetches the new bundle. See TIM-446.
570
- // Set hard-navigating flag to prevent Navigation API interception
571
- // and React from rendering during page teardown. See TIM-626.
572
569
  if (error instanceof VersionSkewError) {
573
570
  setHardNavigating(true);
574
- // Import triggerStaleReload dynamically to avoid circular deps
575
- // and keep the reload logic centralized with its loop guard.
576
- const { triggerStaleReload } = await import('./stale-reload.js');
577
- triggerStaleReload();
578
- // Return a never-resolving promise — page is reloading.
571
+ window.location.reload();
579
572
  return new Promise(() => {}) as never;
580
573
  }
581
574
  // Server-side redirect during RSC fetch → soft router navigation.
@@ -600,13 +593,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
600
593
  window.location.href = error.url;
601
594
  return new Promise(() => {}) as never;
602
595
  }
603
- // Stale client reference from streaming RSC decode — the server
604
- // was redeployed with new bundles. Same recovery as VersionSkewError.
605
- if (isStaleClientReference(error)) {
606
- setHardNavigating(true);
607
- triggerStaleReload();
608
- return new Promise(() => {}) as never;
609
- }
610
596
  // Abort errors are not application errors — swallow silently.
611
597
  if (isAbortError(error)) return;
612
598
  throw error;
@@ -651,13 +637,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
651
637
 
652
638
  applyHead(headElements);
653
639
  } catch (error) {
654
- if (isStaleClientReference(error)) {
655
- setHardNavigating(true);
656
- triggerStaleReload();
657
- return new Promise(() => {}) as never;
658
- }
659
- // Stale transition (superseded by a newer navigation) or aborted
660
- // fetch — silently ignore. See TIM-629.
661
640
  if (isAbortError(error)) return;
662
641
  throw error;
663
642
  } finally {
@@ -705,13 +684,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
705
684
  applyHead(headElements);
706
685
  restoreScrollAfterPaint(scrollY);
707
686
  } catch (error) {
708
- if (isStaleClientReference(error)) {
709
- setHardNavigating(true);
710
- triggerStaleReload();
711
- return new Promise(() => {}) as never;
712
- }
713
- // Stale transition (superseded by a newer navigation) or aborted
714
- // fetch — silently ignore. See TIM-629.
715
687
  if (isAbortError(error)) return;
716
688
  throw error;
717
689
  } finally {
@@ -230,7 +230,7 @@ export class RedirectError extends Error {
230
230
 
231
231
  /**
232
232
  * Thrown when the server signals a version skew (X-Timber-Reload header).
233
- * Caught in navigate() to trigger a full page reload via triggerStaleReload().
233
+ * Caught in navigate() to trigger a full page reload.
234
234
  * See TIM-446.
235
235
  */
236
236
  export class VersionSkewError extends Error {
@@ -345,7 +345,7 @@ export async function fetchRscPayload(
345
345
  const wrappedPromise = fetchPromise.then((response) => {
346
346
  // Version skew detection (TIM-446): if the server signals a reload,
347
347
  // throw VersionSkewError so the caller (router navigate) can trigger
348
- // a full page reload via triggerStaleReload().
348
+ // a full page reload.
349
349
  if (checkReloadSignal(response)) {
350
350
  throw new VersionSkewError();
351
351
  }
@@ -92,12 +92,12 @@ export function collectRouteCss(segments: SegmentWithFiles[], manifest: BuildMan
92
92
  * via injectHead() before </head>.
93
93
  */
94
94
  export function buildCssLinkTags(cssUrls: string[]): string {
95
- // Emit <link rel="stylesheet"> tags as a fallback for platforms where
96
- // React's Float system (via @vitejs/plugin-rsc preinit with
97
- // data-precedence) doesn't handle CSS injection. In practice, Float
98
- // deduplicates and these may be dropped. No preload hints — Float
99
- // already starts the fetch via preinit(), and redundant preloads
100
- // cause "ignored due to unknown as/type" browser warnings.
95
+ // Emit only <link rel="stylesheet"> — no preload tags. CSS preloading
96
+ // is handled by 103 Early Hints (Link header) which fires before the
97
+ // HTML stream. Float also emits `<link rel="stylesheet" data-precedence>`
98
+ // at the top of <head> during Fizz rendering — the browser discovers
99
+ // CSS from that tag. A <link rel="preload"> here would arrive *after*
100
+ // Float's stylesheet tag and trigger "Preload was ignored" warnings.
101
101
  return cssUrls.map((url) => `<link rel="stylesheet" href="${url}">`).join('');
102
102
  }
103
103
 
@@ -127,6 +127,8 @@ export function collectEarlyHintHeaders(
127
127
  };
128
128
 
129
129
  // Per-route CSS — as=style; rel=preload
130
+ // The HTML <head> also contains a matching <link rel="preload" as="style">
131
+ // tag so browsers can deduplicate the 103 hint against the HTML tag.
130
132
  for (const url of collectRouteCss(segments, manifest)) {
131
133
  add(url, formatLinkHeader({ href: url, rel: 'preload', as: 'style' }));
132
134
  }
@@ -1,59 +0,0 @@
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
- * Check if an error is a stale client reference error from React's
18
- * Flight client. These errors have the message pattern:
19
- * "Could not find the module \"<id>\""
20
- *
21
- * This is thrown by react-server-dom-webpack's client when the RSC
22
- * payload references a module ID that doesn't exist in the client's
23
- * module map — typically because the server has been redeployed with
24
- * new bundle hashes while the client is still running old JavaScript.
25
- */
26
- export declare function isStaleClientReference(error: unknown): boolean;
27
- /**
28
- * Check if an error is a chunk load failure from a dynamic import.
29
- *
30
- * After a deployment, old chunk filenames no longer exist. When the client
31
- * tries to dynamically import a chunk that's been replaced, the browser
32
- * throws one of these errors:
33
- *
34
- * - Chromium: "Failed to fetch dynamically imported module: <url>"
35
- * - Firefox: "error loading dynamically imported module: <url>"
36
- * - Safari: "Importing a module script failed."
37
- * - Vite/Rollup: "Unable to preload CSS for <url>"
38
- *
39
- * See TIM-446
40
- */
41
- export declare function isChunkLoadError(error: unknown): boolean;
42
- /**
43
- * Trigger a full page reload to pick up new bundles.
44
- *
45
- * Sets a sessionStorage flag before reloading. If the flag is already
46
- * set (meaning we already reloaded once for this reason), we don't
47
- * reload again — this prevents infinite reload loops if the error
48
- * persists after reload (e.g., a genuine bug rather than a stale bundle).
49
- *
50
- * @returns true if a reload was triggered, false if suppressed (loop guard)
51
- */
52
- export declare function triggerStaleReload(): boolean;
53
- /**
54
- * Clear the stale reload flag. Called on successful bootstrap to reset
55
- * the loop guard — if the page loaded successfully, the next stale
56
- * reference error should trigger a fresh reload attempt.
57
- */
58
- export declare function clearStaleReloadFlag(): void;
59
- //# sourceMappingURL=stale-reload.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"stale-reload.d.ts","sourceRoot":"","sources":["../../src/client/stale-reload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAcH;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAI9D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAYxD;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,IAAI,OAAO,CAqD5C;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAa3C"}
@@ -1,156 +0,0 @@
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
- * In-memory fallback counter for environments where sessionStorage is
21
- * unavailable (private browsing, storage full, extension interference).
22
- * Incremented each time triggerStaleReload() falls into the catch path.
23
- * If the counter exceeds 0 on a subsequent call, the reload is suppressed
24
- * to prevent an infinite loop. Resets naturally on page load (module
25
- * re-evaluates) and can be manually reset via clearStaleReloadFlag().
26
- */
27
- let memoryReloadCount = 0;
28
-
29
- /**
30
- * Check if an error is a stale client reference error from React's
31
- * Flight client. These errors have the message pattern:
32
- * "Could not find the module \"<id>\""
33
- *
34
- * This is thrown by react-server-dom-webpack's client when the RSC
35
- * payload references a module ID that doesn't exist in the client's
36
- * module map — typically because the server has been redeployed with
37
- * new bundle hashes while the client is still running old JavaScript.
38
- */
39
- export function isStaleClientReference(error: unknown): boolean {
40
- if (!(error instanceof Error)) return false;
41
- const msg = error.message;
42
- return msg.includes('Could not find the module') || msg.includes('client reference not found');
43
- }
44
-
45
- /**
46
- * Check if an error is a chunk load failure from a dynamic import.
47
- *
48
- * After a deployment, old chunk filenames no longer exist. When the client
49
- * tries to dynamically import a chunk that's been replaced, the browser
50
- * throws one of these errors:
51
- *
52
- * - Chromium: "Failed to fetch dynamically imported module: <url>"
53
- * - Firefox: "error loading dynamically imported module: <url>"
54
- * - Safari: "Importing a module script failed."
55
- * - Vite/Rollup: "Unable to preload CSS for <url>"
56
- *
57
- * See TIM-446
58
- */
59
- export function isChunkLoadError(error: unknown): boolean {
60
- if (!(error instanceof Error)) return false;
61
- const msg = error.message.toLowerCase();
62
- return (
63
- msg.includes('failed to fetch dynamically imported module') ||
64
- msg.includes('error loading dynamically imported module') ||
65
- msg.includes('importing a module script failed') ||
66
- msg.includes('unable to preload css') ||
67
- // Webpack-style chunk load errors (unlikely in Vite but defensive)
68
- msg.includes('loading chunk') ||
69
- msg.includes('loading css chunk')
70
- );
71
- }
72
-
73
- /**
74
- * Trigger a full page reload to pick up new bundles.
75
- *
76
- * Sets a sessionStorage flag before reloading. If the flag is already
77
- * set (meaning we already reloaded once for this reason), we don't
78
- * reload again — this prevents infinite reload loops if the error
79
- * persists after reload (e.g., a genuine bug rather than a stale bundle).
80
- *
81
- * @returns true if a reload was triggered, false if suppressed (loop guard)
82
- */
83
- export function triggerStaleReload(): boolean {
84
- try {
85
- // Check if we already reloaded — prevent infinite loop
86
- if (sessionStorage.getItem(RELOAD_FLAG_KEY)) {
87
- console.warn(
88
- '[timber] Stale client reference detected again after reload. ' +
89
- 'Not reloading to prevent infinite loop. ' +
90
- 'This may indicate a deployment issue — try a hard refresh.'
91
- );
92
- return false;
93
- }
94
-
95
- // Set the flag before reloading
96
- sessionStorage.setItem(RELOAD_FLAG_KEY, '1');
97
-
98
- console.warn(
99
- '[timber] Stale client reference detected — the server has been ' +
100
- 'redeployed with new bundles. Reloading to pick up the new version.'
101
- );
102
-
103
- window.location.reload();
104
- return true;
105
- } catch {
106
- // sessionStorage unavailable (private browsing, storage full, etc.)
107
- // Use document.cookie as a reload-persistent fallback loop guard.
108
- // Module-level memoryReloadCount resets on every reload, so it can't
109
- // detect cross-reload loops. Cookies persist across reloads and are
110
- // available even when sessionStorage is blocked (TIM-576).
111
- const cookieFlag = document.cookie.includes(RELOAD_FLAG_KEY + '=1');
112
- if (cookieFlag || memoryReloadCount > 0) {
113
- console.warn(
114
- '[timber] Stale client reference detected again after reload. ' +
115
- 'Not reloading to prevent infinite loop. ' +
116
- 'This may indicate a deployment issue — try a hard refresh.'
117
- );
118
- return false;
119
- }
120
-
121
- memoryReloadCount++;
122
- // Set a short-lived cookie (60s) as the persistent loop guard.
123
- // It auto-expires so it won't block future legitimate reloads.
124
- try {
125
- document.cookie = `${RELOAD_FLAG_KEY}=1; max-age=60; path=/; SameSite=Lax`;
126
- } catch {
127
- // Cookie API unavailable — proceed anyway, memoryReloadCount guards same-page loops
128
- }
129
- console.warn(
130
- '[timber] Stale client reference detected — the server has been ' +
131
- 'redeployed with new bundles. Reloading to pick up the new version.'
132
- );
133
- window.location.reload();
134
- return true;
135
- }
136
- }
137
-
138
- /**
139
- * Clear the stale reload flag. Called on successful bootstrap to reset
140
- * the loop guard — if the page loaded successfully, the next stale
141
- * reference error should trigger a fresh reload attempt.
142
- */
143
- export function clearStaleReloadFlag(): void {
144
- memoryReloadCount = 0;
145
- try {
146
- sessionStorage.removeItem(RELOAD_FLAG_KEY);
147
- } catch {
148
- // sessionStorage unavailable — nothing to clear
149
- }
150
- // Also clear the cookie fallback
151
- try {
152
- document.cookie = `${RELOAD_FLAG_KEY}=; max-age=0; path=/; SameSite=Lax`;
153
- } catch {
154
- // Cookie API unavailable
155
- }
156
- }