@timber-js/app 0.2.0-alpha.71 → 0.2.0-alpha.73

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 (248) hide show
  1. package/dist/_chunks/actions-Dg-ANYHb.js +421 -0
  2. package/dist/_chunks/actions-Dg-ANYHb.js.map +1 -0
  3. package/dist/_chunks/{als-registry-BJARkOcu.js → als-registry-HS0LGUl2.js} +1 -1
  4. package/dist/_chunks/als-registry-HS0LGUl2.js.map +1 -0
  5. package/dist/_chunks/{define-Dz1bqwaS.js → define-C77ScO0m.js} +14 -14
  6. package/dist/_chunks/define-C77ScO0m.js.map +1 -0
  7. package/dist/_chunks/{define-CGuYoRHU.js → define-CZqDwhSu.js} +15 -15
  8. package/dist/_chunks/define-CZqDwhSu.js.map +1 -0
  9. package/dist/_chunks/{define-cookie-B5mewxwM.js → define-cookie-C2IkoFGN.js} +9 -8
  10. package/dist/_chunks/{define-cookie-B5mewxwM.js.map → define-cookie-C2IkoFGN.js.map} +1 -1
  11. package/dist/_chunks/{format-Rn922VH2.js → dev-warnings-DpGRGoDi.js} +4 -26
  12. package/dist/_chunks/dev-warnings-DpGRGoDi.js.map +1 -0
  13. package/dist/_chunks/format-CYBGxKtc.js +14 -0
  14. package/dist/_chunks/format-CYBGxKtc.js.map +1 -0
  15. package/dist/_chunks/{interception-CEdHHviP.js → interception-Dpn_UfAD.js} +2 -2
  16. package/dist/_chunks/{interception-CEdHHviP.js.map → interception-Dpn_UfAD.js.map} +1 -1
  17. package/dist/_chunks/{segment-context-hzuJ048X.js → merge-search-params-Cm_KIWDX.js} +2 -33
  18. package/dist/_chunks/merge-search-params-Cm_KIWDX.js.map +1 -0
  19. package/dist/_chunks/{request-context-CywiO4jV.js → request-context-qMsWgy9C.js} +72 -36
  20. package/dist/_chunks/request-context-qMsWgy9C.js.map +1 -0
  21. package/dist/_chunks/{schema-bridge-C4SwjCQD.js → schema-bridge-C3xl_vfb.js} +1 -1
  22. package/dist/_chunks/{schema-bridge-C4SwjCQD.js.map → schema-bridge-C3xl_vfb.js.map} +1 -1
  23. package/dist/_chunks/segment-context-fHFLF1PE.js +34 -0
  24. package/dist/_chunks/segment-context-fHFLF1PE.js.map +1 -0
  25. package/dist/_chunks/ssr-data-DzuI0bIV.js +88 -0
  26. package/dist/_chunks/ssr-data-DzuI0bIV.js.map +1 -0
  27. package/dist/_chunks/{stale-reload-BLUC_Pl_.js → stale-reload-C2plcNtG.js} +1 -1
  28. package/dist/_chunks/{stale-reload-BLUC_Pl_.js.map → stale-reload-C2plcNtG.js.map} +1 -1
  29. package/dist/_chunks/{handler-store-BVePM7hp.js → tracing-CCYbKn5n.js} +60 -60
  30. package/dist/_chunks/tracing-CCYbKn5n.js.map +1 -0
  31. package/dist/_chunks/use-params-B1AuhI1p.js +307 -0
  32. package/dist/_chunks/use-params-B1AuhI1p.js.map +1 -0
  33. package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-Lo_s_pw2.js} +4 -4
  34. package/dist/_chunks/use-query-states-Lo_s_pw2.js.map +1 -0
  35. package/dist/_chunks/{wrappers-LZbghvn0.js → wrappers-_DTmImGt.js} +1 -1
  36. package/dist/_chunks/{wrappers-LZbghvn0.js.map → wrappers-_DTmImGt.js.map} +1 -1
  37. package/dist/adapters/cloudflare-kv-cache.d.ts +64 -0
  38. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -0
  39. package/dist/adapters/cloudflare-kv-cache.js +95 -0
  40. package/dist/adapters/cloudflare-kv-cache.js.map +1 -0
  41. package/dist/cache/index.d.ts +18 -4
  42. package/dist/cache/index.d.ts.map +1 -1
  43. package/dist/cache/index.js +78 -12
  44. package/dist/cache/index.js.map +1 -1
  45. package/dist/cache/sizeof.d.ts +22 -0
  46. package/dist/cache/sizeof.d.ts.map +1 -0
  47. package/dist/cli.d.ts +6 -1
  48. package/dist/cli.d.ts.map +1 -1
  49. package/dist/cli.js +6 -1
  50. package/dist/cli.js.map +1 -1
  51. package/dist/client/browser-dev.d.ts +27 -1
  52. package/dist/client/browser-dev.d.ts.map +1 -1
  53. package/dist/client/browser-entry/action-dispatch.d.ts +17 -0
  54. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -0
  55. package/dist/client/browser-entry/hmr.d.ts +21 -0
  56. package/dist/client/browser-entry/hmr.d.ts.map +1 -0
  57. package/dist/client/browser-entry/hydrate.d.ts +46 -0
  58. package/dist/client/browser-entry/hydrate.d.ts.map +1 -0
  59. package/dist/client/browser-entry/index.d.ts +30 -0
  60. package/dist/client/browser-entry/index.d.ts.map +1 -0
  61. package/dist/client/browser-entry/post-hydration.d.ts +26 -0
  62. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -0
  63. package/dist/client/browser-entry/router-init.d.ts +23 -0
  64. package/dist/client/browser-entry/router-init.d.ts.map +1 -0
  65. package/dist/client/browser-entry/rsc-stream.d.ts +24 -0
  66. package/dist/client/browser-entry/rsc-stream.d.ts.map +1 -0
  67. package/dist/client/browser-entry/scroll.d.ts +19 -0
  68. package/dist/client/browser-entry/scroll.d.ts.map +1 -0
  69. package/dist/client/error-boundary.js +131 -1
  70. package/dist/client/error-boundary.js.map +1 -0
  71. package/dist/client/index.d.ts +4 -19
  72. package/dist/client/index.d.ts.map +1 -1
  73. package/dist/client/index.js +14 -1191
  74. package/dist/client/index.js.map +1 -1
  75. package/dist/client/internal.d.ts +18 -0
  76. package/dist/client/internal.d.ts.map +1 -0
  77. package/dist/client/internal.js +890 -0
  78. package/dist/client/internal.js.map +1 -0
  79. package/dist/client/navigation-context.d.ts.map +1 -1
  80. package/dist/client/router-ref.d.ts +1 -1
  81. package/dist/client/top-loader.d.ts +2 -2
  82. package/dist/client/use-link-status.d.ts +1 -1
  83. package/dist/client/{use-navigation-pending.d.ts → use-pending-navigation.d.ts} +4 -4
  84. package/dist/client/use-pending-navigation.d.ts.map +1 -0
  85. package/dist/client/use-router.d.ts +1 -1
  86. package/dist/codec.d.ts +10 -0
  87. package/dist/codec.d.ts.map +1 -1
  88. package/dist/codec.js +1 -1
  89. package/dist/config-types.d.ts +210 -0
  90. package/dist/config-types.d.ts.map +1 -0
  91. package/dist/content/index.d.ts +1 -10
  92. package/dist/content/index.d.ts.map +1 -1
  93. package/dist/content/index.js +0 -2
  94. package/dist/cookies/define-cookie.d.ts.map +1 -1
  95. package/dist/cookies/index.d.ts +0 -2
  96. package/dist/cookies/index.d.ts.map +1 -1
  97. package/dist/cookies/index.js +2 -3
  98. package/dist/index.d.ts +25 -288
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +261 -43
  101. package/dist/index.js.map +1 -1
  102. package/dist/plugin-context.d.ts +84 -0
  103. package/dist/plugin-context.d.ts.map +1 -0
  104. package/dist/plugins/adapter-build.d.ts +1 -1
  105. package/dist/plugins/adapter-build.d.ts.map +1 -1
  106. package/dist/plugins/build-manifest.d.ts +1 -1
  107. package/dist/plugins/build-manifest.d.ts.map +1 -1
  108. package/dist/plugins/build-report.d.ts +1 -1
  109. package/dist/plugins/build-report.d.ts.map +1 -1
  110. package/dist/plugins/content.d.ts +1 -1
  111. package/dist/plugins/content.d.ts.map +1 -1
  112. package/dist/plugins/dev-browser-logs.d.ts +1 -1
  113. package/dist/plugins/dev-browser-logs.d.ts.map +1 -1
  114. package/dist/plugins/dev-logs.d.ts +1 -1
  115. package/dist/plugins/dev-logs.d.ts.map +1 -1
  116. package/dist/plugins/dev-server.d.ts +1 -1
  117. package/dist/plugins/dev-server.d.ts.map +1 -1
  118. package/dist/plugins/entries.d.ts +1 -1
  119. package/dist/plugins/entries.d.ts.map +1 -1
  120. package/dist/plugins/fonts.d.ts +1 -1
  121. package/dist/plugins/fonts.d.ts.map +1 -1
  122. package/dist/plugins/mdx.d.ts +1 -1
  123. package/dist/plugins/mdx.d.ts.map +1 -1
  124. package/dist/plugins/routing.d.ts +1 -1
  125. package/dist/plugins/routing.d.ts.map +1 -1
  126. package/dist/plugins/shims.d.ts +1 -1
  127. package/dist/plugins/shims.d.ts.map +1 -1
  128. package/dist/plugins/static-build.d.ts +4 -4
  129. package/dist/plugins/static-build.d.ts.map +1 -1
  130. package/dist/routing/index.js +1 -1
  131. package/dist/search-params/define.d.ts +6 -6
  132. package/dist/search-params/define.d.ts.map +1 -1
  133. package/dist/search-params/index.d.ts +1 -2
  134. package/dist/search-params/index.d.ts.map +1 -1
  135. package/dist/search-params/index.js +4 -4
  136. package/dist/search-params/registry.d.ts +1 -1
  137. package/dist/search-params/registry.d.ts.map +1 -1
  138. package/dist/segment-params/define.d.ts +6 -6
  139. package/dist/segment-params/define.d.ts.map +1 -1
  140. package/dist/segment-params/index.d.ts +0 -1
  141. package/dist/segment-params/index.d.ts.map +1 -1
  142. package/dist/segment-params/index.js +3 -3
  143. package/dist/server/als-registry.d.ts +1 -1
  144. package/dist/server/dev-holding-server.d.ts +52 -0
  145. package/dist/server/dev-holding-server.d.ts.map +1 -0
  146. package/dist/server/dev-warnings.d.ts +1 -7
  147. package/dist/server/dev-warnings.d.ts.map +1 -1
  148. package/dist/server/index.d.ts +6 -45
  149. package/dist/server/index.d.ts.map +1 -1
  150. package/dist/server/index.js +7 -3272
  151. package/dist/server/index.js.map +1 -1
  152. package/dist/server/internal.d.ts +46 -0
  153. package/dist/server/internal.d.ts.map +1 -0
  154. package/dist/server/internal.js +2865 -0
  155. package/dist/server/internal.js.map +1 -0
  156. package/dist/server/pipeline.d.ts.map +1 -1
  157. package/dist/server/primitives.d.ts +41 -17
  158. package/dist/server/primitives.d.ts.map +1 -1
  159. package/dist/server/request-context.d.ts +45 -15
  160. package/dist/server/request-context.d.ts.map +1 -1
  161. package/dist/server/tracing.d.ts +4 -4
  162. package/dist/server/tracing.d.ts.map +1 -1
  163. package/dist/shims/headers.d.ts +2 -1
  164. package/dist/shims/headers.d.ts.map +1 -1
  165. package/dist/shims/navigation.d.ts +2 -1
  166. package/dist/shims/navigation.d.ts.map +1 -1
  167. package/package.json +19 -13
  168. package/src/adapters/cloudflare-kv-cache.ts +142 -0
  169. package/src/cache/handler-store.ts +2 -2
  170. package/src/cache/index.ts +74 -15
  171. package/src/cache/sizeof.ts +31 -0
  172. package/src/cli.ts +6 -1
  173. package/src/client/browser-dev.ts +128 -1
  174. package/src/client/browser-entry/action-dispatch.ts +116 -0
  175. package/src/client/browser-entry/hmr.ts +81 -0
  176. package/src/client/browser-entry/hydrate.ts +145 -0
  177. package/src/client/browser-entry/index.ts +138 -0
  178. package/src/client/browser-entry/post-hydration.ts +119 -0
  179. package/src/client/browser-entry/router-init.ts +184 -0
  180. package/src/client/browser-entry/rsc-stream.ts +157 -0
  181. package/src/client/browser-entry/scroll.ts +27 -0
  182. package/src/client/index.ts +10 -38
  183. package/src/client/internal.ts +57 -0
  184. package/src/client/navigation-context.ts +6 -2
  185. package/src/client/navigation-root.tsx +1 -1
  186. package/src/client/router-ref.ts +1 -1
  187. package/src/client/top-loader.tsx +2 -2
  188. package/src/client/use-link-status.ts +1 -1
  189. package/src/client/{use-navigation-pending.ts → use-pending-navigation.ts} +5 -5
  190. package/src/client/use-query-states.ts +2 -2
  191. package/src/client/use-router.ts +1 -1
  192. package/src/codec.ts +15 -0
  193. package/src/config-types.ts +208 -0
  194. package/src/content/index.ts +5 -13
  195. package/src/cookies/define-cookie.ts +9 -7
  196. package/src/cookies/index.ts +6 -5
  197. package/src/index.ts +84 -416
  198. package/src/plugin-context.ts +200 -0
  199. package/src/plugins/adapter-build.ts +1 -1
  200. package/src/plugins/build-manifest.ts +1 -1
  201. package/src/plugins/build-report.ts +1 -1
  202. package/src/plugins/content.ts +1 -1
  203. package/src/plugins/dev-browser-logs.ts +1 -1
  204. package/src/plugins/dev-logs.ts +1 -1
  205. package/src/plugins/dev-server.ts +16 -1
  206. package/src/plugins/entries.ts +2 -2
  207. package/src/plugins/fonts.ts +4 -3
  208. package/src/plugins/mdx.ts +1 -1
  209. package/src/plugins/routing.ts +1 -1
  210. package/src/plugins/shims.ts +53 -5
  211. package/src/plugins/static-build.ts +8 -6
  212. package/src/search-params/define.ts +22 -22
  213. package/src/search-params/index.ts +3 -3
  214. package/src/search-params/registry.ts +1 -1
  215. package/src/segment-params/define.ts +18 -18
  216. package/src/segment-params/index.ts +2 -1
  217. package/src/server/action-handler.ts +1 -1
  218. package/src/server/als-registry.ts +3 -3
  219. package/src/server/dev-holding-server.ts +185 -0
  220. package/src/server/dev-warnings.ts +2 -21
  221. package/src/server/html-injectors.ts +3 -3
  222. package/src/server/index.ts +25 -180
  223. package/src/server/internal.ts +169 -0
  224. package/src/server/pipeline.ts +12 -7
  225. package/src/server/primitives.ts +71 -30
  226. package/src/server/request-context.ts +77 -39
  227. package/src/server/route-element-builder.ts +1 -1
  228. package/src/server/rsc-entry/index.ts +2 -2
  229. package/src/server/rsc-entry/ssr-renderer.ts +1 -1
  230. package/src/server/slot-resolver.ts +1 -1
  231. package/src/server/tracing.ts +6 -6
  232. package/src/server/tree-builder.ts +1 -1
  233. package/src/shims/headers.ts +5 -1
  234. package/src/shims/navigation.ts +5 -1
  235. package/dist/_chunks/als-registry-BJARkOcu.js.map +0 -1
  236. package/dist/_chunks/define-CGuYoRHU.js.map +0 -1
  237. package/dist/_chunks/define-Dz1bqwaS.js.map +0 -1
  238. package/dist/_chunks/error-boundary-D9hzsveV.js +0 -216
  239. package/dist/_chunks/error-boundary-D9hzsveV.js.map +0 -1
  240. package/dist/_chunks/format-Rn922VH2.js.map +0 -1
  241. package/dist/_chunks/handler-store-BVePM7hp.js.map +0 -1
  242. package/dist/_chunks/request-context-CywiO4jV.js.map +0 -1
  243. package/dist/_chunks/segment-context-hzuJ048X.js.map +0 -1
  244. package/dist/_chunks/use-query-states-DAhgj8Gx.js.map +0 -1
  245. package/dist/client/browser-entry.d.ts +0 -21
  246. package/dist/client/browser-entry.d.ts.map +0 -1
  247. package/dist/client/use-navigation-pending.d.ts.map +0 -1
  248. package/src/client/browser-entry.ts +0 -846
@@ -1,846 +0,0 @@
1
- /**
2
- * Browser Entry — Client-side hydration and navigation bootstrap.
3
- *
4
- * This is a real TypeScript file, not codegen. It initializes the
5
- * client navigation runtime: segment router, prefetch cache, and
6
- * history stack.
7
- *
8
- * Hydration works by:
9
- * 1. Decoding the RSC payload embedded in the initial HTML response
10
- * via createFromReadableStream from @vitejs/plugin-rsc/browser
11
- * 2. Hydrating the decoded React tree via hydrateRoot
12
- * 3. Setting up client-side navigation for subsequent page transitions
13
- *
14
- * After hydration, the browser entry:
15
- * - Link click handling is per-component (Link's onClick), not global delegation
16
- * - Listens for popstate events for back/forward navigation
17
- *
18
- * Design docs: 18-build-system.md §"Entry Files", 19-client-navigation.md
19
- */
20
-
21
- // @ts-expect-error — virtual module provided by timber-entries plugin
22
- import config from 'virtual:timber-config';
23
-
24
- import { createElement } from 'react';
25
- import { hydrateRoot, createRoot, type Root } from 'react-dom/client';
26
- import {
27
- createFromReadableStream,
28
- createFromFetch,
29
- setServerCallback,
30
- encodeReply,
31
- } from '../rsc-runtime/browser.js';
32
- // Shared-state modules MUST be imported from @timber-js/app/client (the public
33
- // barrel) so they resolve to the same module instances as user code. In Vite dev,
34
- // user code imports @timber-js/app/client from dist/ via package.json exports.
35
- // If we used relative imports (./router-ref.js), Vite would load separate src/
36
- // copies with separate module-level state — e.g., globalRouter set here but
37
- // read as null from the dist/ copy used by useRouter().
38
- import {
39
- createRouter,
40
- setGlobalRouter,
41
- getRouter,
42
- getRouterOrNull,
43
- setCurrentParams,
44
- } from '@timber-js/app/client';
45
- import type { RouterDeps, RouterInstance } from '@timber-js/app/client';
46
-
47
- // Internal-only modules (no shared mutable state with user code) use relative
48
- // imports — they don't need singleton behavior across module graphs.
49
- import { applyHeadElements } from './head.js';
50
- import { TimberNuqsAdapter } from './nuqs-adapter.js';
51
- import { isPageUnloading } from './unload-guard.js';
52
- import {
53
- NavigationProvider,
54
- getNavigationState,
55
- setNavigationState,
56
- type NavigationState,
57
- } from './navigation-context.js';
58
- import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.js';
59
- // browser-links.ts removed — Link components own their click/hover handlers directly.
60
- // See LOCAL-340.
61
- import {
62
- NavigationRoot,
63
- transitionRender,
64
- navigateTransition,
65
- installDeferredNavigation,
66
- setHardNavigating,
67
- } from './navigation-root.js';
68
- import {
69
- isStaleClientReference,
70
- isChunkLoadError,
71
- triggerStaleReload,
72
- clearStaleReloadFlag,
73
- } from './stale-reload.js';
74
- import {
75
- setClientDeploymentId,
76
- getClientDeploymentId,
77
- DEPLOYMENT_ID_HEADER,
78
- RELOAD_HEADER,
79
- } from './rsc-fetch.js';
80
- import {
81
- hasNavigationApi,
82
- setupNavigationApi,
83
- type NavigationApiController,
84
- } from './navigation-api.js';
85
-
86
- // ─── Server Action Dispatch ──────────────────────────────────────
87
-
88
- /**
89
- * Register the callServer callback for server action dispatch.
90
- *
91
- * When React encounters a server reference (from `'use server'` modules),
92
- * it calls `callServer(id, args)` to dispatch the action to the server.
93
- * The RSC plugin delegates to `globalThis.__viteRscCallServer` which is
94
- * set by `setServerCallback`.
95
- *
96
- * The callback:
97
- * 1. Serializes args via `encodeReply` (RSC wire format)
98
- * 2. POSTs to the current URL with `Accept: text/x-component`
99
- * 3. Decodes the RSC response stream
100
- *
101
- * See design/08-forms-and-actions.md §"Client-Side Form Mechanics"
102
- */
103
- setServerCallback(async (id: string, args: unknown[]) => {
104
- const body = await encodeReply(args);
105
-
106
- // Track the X-Timber-Revalidation header from the response.
107
- // We intercept the fetch promise to read headers before createFromFetch
108
- // consumes the body stream.
109
- let hasRevalidation = false;
110
- let hasRedirect = false;
111
- let headElementsJson: string | null = null;
112
-
113
- // Build action request headers. Include deployment ID for version
114
- // skew detection (TIM-446) — the server rejects stale actions gracefully.
115
- const actionHeaders: Record<string, string> = {
116
- 'Accept': 'text/x-component',
117
- 'x-rsc-action': id,
118
- };
119
- const actionDeploymentId = getClientDeploymentId();
120
- if (actionDeploymentId) {
121
- actionHeaders[DEPLOYMENT_ID_HEADER] = actionDeploymentId;
122
- }
123
-
124
- const response = fetch(window.location.href, {
125
- method: 'POST',
126
- headers: actionHeaders,
127
- body,
128
- }).then((res) => {
129
- // Version skew detection (TIM-446): if the server signals a reload,
130
- // trigger a full page load to pick up the new deployment.
131
- if (res.headers.get(RELOAD_HEADER) === '1') {
132
- window.location.reload();
133
- throw new Error('Version skew detected — reloading page');
134
- }
135
- hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
136
- hasRedirect = res.headers.get('X-Timber-Redirect') != null;
137
- headElementsJson = res.headers.get('X-Timber-Head');
138
- return res;
139
- });
140
-
141
- let decoded: unknown;
142
- try {
143
- decoded = await createFromFetch(response);
144
- } catch (error) {
145
- if (isStaleClientReference(error)) {
146
- triggerStaleReload();
147
- // Return a never-resolving promise to prevent further processing
148
- return new Promise(() => {});
149
- }
150
- throw error;
151
- }
152
-
153
- // Handle redirect — server encoded the redirect location in the RSC stream
154
- // instead of returning HTTP 302. Perform a client-side SPA navigation.
155
- if (hasRedirect) {
156
- const wrapper = decoded as { _redirect: string; _status: number };
157
- try {
158
- const router = getRouter();
159
- void router.navigate(wrapper._redirect);
160
- } catch {
161
- // Router not yet initialized — fall back to full navigation.
162
- // Set hard-navigating flag to prevent Navigation API interception
163
- // and React from rendering during page teardown. See TIM-626.
164
- setHardNavigating(true);
165
- window.location.href = wrapper._redirect;
166
- }
167
- return undefined;
168
- }
169
-
170
- if (hasRevalidation) {
171
- // Piggybacked response: wrapper object { _action, _tree }
172
- // Apply the revalidated tree directly — no separate router.refresh() needed.
173
- const wrapper = decoded as { _action: unknown; _tree: unknown };
174
- try {
175
- const router = getRouter();
176
- const headElements = headElementsJson ? JSON.parse(headElementsJson) : null;
177
- router.applyRevalidation(wrapper._tree, headElements);
178
- } catch {
179
- // Router not yet initialized — fall through
180
- }
181
- return wrapper._action;
182
- }
183
-
184
- // No piggybacked revalidation — refresh to pick up any mutations.
185
- // This covers actions that don't call revalidatePath().
186
- try {
187
- const router = getRouter();
188
- void router.refresh();
189
- } catch {
190
- // Router not yet initialized (rare edge case during bootstrap)
191
- }
192
-
193
- return decoded;
194
- });
195
-
196
- // ─── Bootstrap ───────────────────────────────────────────────────
197
-
198
- /**
199
- * Bootstrap the client-side runtime.
200
- *
201
- * Hydrates the server-rendered HTML with React, then initializes
202
- * client-side navigation for SPA transitions.
203
- */
204
- /**
205
- * Read the current scroll position.
206
- *
207
- * Checks window scroll first, then explicit `data-timber-scroll-restoration`
208
- * containers. With segment tree merging, shared layouts are reconciled in
209
- * place via `cloneElement` — React preserves their DOM and scroll state
210
- * naturally. We don't need to auto-detect overflow containers; only
211
- * explicitly marked containers are tracked.
212
- *
213
- * See design/19-client-navigation.md §"Overflow Scroll Containers".
214
- */
215
- function getScrollY(): number {
216
- if (window.scrollY || document.documentElement.scrollTop || document.body.scrollTop) {
217
- return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop;
218
- }
219
- for (const el of document.querySelectorAll('[data-timber-scroll-restoration]')) {
220
- if ((el as HTMLElement).scrollTop > 0) return (el as HTMLElement).scrollTop;
221
- }
222
- return 0;
223
- }
224
-
225
- function bootstrap(runtimeConfig: typeof config): void {
226
- const _config = runtimeConfig;
227
-
228
- // Initialize deployment ID for version skew detection (TIM-446).
229
- // In dev mode this is null — skew checks are skipped.
230
- const deploymentId = (_config as Record<string, unknown>).deploymentId as string | null;
231
- if (deploymentId) {
232
- setClientDeploymentId(deploymentId);
233
- }
234
-
235
- // Take manual control of scroll restoration. Even though segment tree
236
- // merging preserves shared layout DOM via cloneElement (so React doesn't
237
- // reset scroll on those elements), the root-level reactRoot.render() with
238
- // a new element tree can still cause scroll resets on the document during
239
- // reconciliation. Manual control ensures consistent behavior.
240
- window.history.scrollRestoration = 'manual';
241
-
242
- // Hydrate the React tree from the RSC payload.
243
- //
244
- // The RSC payload is embedded in the HTML as progressive inline script
245
- // tags that call self.__timber_f.push([type, data]) as RSC chunks arrive.
246
- // Typed tuples: [0] = bootstrap signal, [1, string] = Flight data chunk.
247
- //
248
- // We set up a ReadableStream fed by those push() calls so
249
- // createFromReadableStream can decode the Flight protocol progressively.
250
- //
251
- // For the initial page load, the RSC payload is inlined in the HTML.
252
- // For subsequent navigations, it's fetched from the server.
253
- type FlightSegment = [isBootstrap: 0] | [isData: 1, data: string];
254
-
255
- // __timber_f is initialized in <head> via flightInitScript() (see
256
- // flight-scripts.ts). If it doesn't exist, skip Flight decoding
257
- // entirely and fall through to the createRoot branch.
258
- // Do NOT defensively create it here: that would cause
259
- // createFromReadableStream to be called on an empty stream, producing
260
- // a "Connection closed" error on hydration. See TIM-552.
261
- const timberChunks = (self as unknown as Record<string, FlightSegment[] | undefined>).__timber_f;
262
-
263
- let _reactRoot: Root | null = null;
264
- let initialElement: unknown = null;
265
- // Declared here so it's accessible after the if/else hydration block.
266
- // Assigned inside initRouter() which is called in both branches.
267
- let router!: RouterInstance;
268
-
269
- // Navigation API controller — initialized when the API is available.
270
- // Declared here (before the hydration if/else) because initRouter()
271
- // is called from runPreHydration() inside both branches, and it
272
- // assigns to this variable. Must be in scope before first use.
273
- let navApiController: NavigationApiController | null = null;
274
-
275
- if (timberChunks) {
276
- const encoder = new TextEncoder();
277
-
278
- // Buffer to hold string data until the stream writer is ready.
279
- // Scripts that execute before hydration starts push data here.
280
- let dataBuffer: string[] | undefined = [];
281
- let streamWriter: ReadableStreamDefaultController<Uint8Array> | null = null;
282
- let streamFlushed = false;
283
-
284
- /** Process a typed tuple from __timber_f. */
285
- function handleSegment(seg: FlightSegment): void {
286
- if (seg[0] === 0) {
287
- // Bootstrap signal — initialize buffer (already done above)
288
- if (!dataBuffer) dataBuffer = [];
289
- } else if (seg[0] === 1) {
290
- // Flight data chunk
291
- if (streamWriter) {
292
- streamWriter.enqueue(encoder.encode(seg[1]));
293
- } else if (dataBuffer) {
294
- dataBuffer.push(seg[1]);
295
- }
296
- }
297
- }
298
-
299
- // Process any chunks that arrived before this script executed.
300
- for (const seg of timberChunks) {
301
- handleSegment(seg);
302
- }
303
- // Clear the array to release memory.
304
- timberChunks.length = 0;
305
-
306
- // Patch push() so subsequent script tags feed data in real time.
307
- (timberChunks as unknown as { push: (seg: FlightSegment) => void }).push = handleSegment;
308
-
309
- const rscPayload = new ReadableStream<Uint8Array>({
310
- start(controller) {
311
- streamWriter = controller;
312
- // Flush buffered data into the stream.
313
- if (dataBuffer) {
314
- for (const data of dataBuffer) {
315
- controller.enqueue(encoder.encode(data));
316
- }
317
- dataBuffer = undefined;
318
- }
319
- // If DOM already loaded (non-streaming or fast page), close now.
320
- if (streamFlushed) {
321
- controller.close();
322
- }
323
- },
324
- });
325
-
326
- // Close the stream when the document finishes loading.
327
- // DOMContentLoaded fires after the HTML parser has processed all
328
- // inline scripts (including streamed Suspense replacements and
329
- // RSC data), so all push() calls have completed by this point.
330
- //
331
- // If the page is unloading (user refreshed or navigated away),
332
- // do NOT close the stream. When the connection drops mid-stream,
333
- // DOMContentLoaded fires because the parser finishes. Closing an
334
- // incomplete RSC stream causes React's Flight client to throw
335
- // "Connection closed." — a jarring error on a page being replaced.
336
- // Leaving the stream open is harmless: the page is being torn down.
337
- function onDOMContentLoaded(): void {
338
- if (isPageUnloading()) return;
339
-
340
- // In dev mode, do NOT close the stream. React's RSC renderer
341
- // includes debug owner/stack references ($1, $14, etc.) in the
342
- // Flight payload that point to rows delivered through the debug
343
- // channel, not the main Flight stream. The browser Flight client
344
- // tracks these as pending chunks. Closing the stream with
345
- // unresolved chunks triggers reportGlobalError("Connection closed")
346
- // which kills the entire React tree.
347
- //
348
- // Leaving the stream open is harmless: React has already received
349
- // all data rows and can hydrate fully. The pending debug chunks
350
- // just remain unresolved (they're only used for React DevTools
351
- // component stacks, not rendering).
352
- //
353
- // In production, debug rows are not emitted, so closing is safe.
354
- if (process.env.NODE_ENV === 'development') {
355
- // Mark as flushed so no more data is buffered, but don't close.
356
- streamFlushed = true;
357
- dataBuffer = undefined;
358
- return;
359
- }
360
-
361
- if (streamWriter && !streamFlushed) {
362
- streamWriter.close();
363
- streamFlushed = true;
364
- dataBuffer = undefined;
365
- }
366
- streamFlushed = true;
367
- }
368
-
369
- if (document.readyState === 'loading') {
370
- document.addEventListener('DOMContentLoaded', onDOMContentLoaded, false);
371
- } else {
372
- // DOM already parsed. All inline RSC <script> tags have already
373
- // executed and pushed their data into the buffer. The buffer was
374
- // flushed into the stream during start() above.
375
- //
376
- // Close via queueMicrotask rather than setTimeout. setTimeout
377
- // defers to the next macrotask, which can race with React's
378
- // Flight client read loop — if React finishes reading all queued
379
- // chunks and issues a reader.read() that pends, the stream is
380
- // NOT closed yet (setTimeout hasn't fired), so React sees an
381
- // open stream and waits. Then setTimeout fires and closes it.
382
- // This works in theory but some React Flight builds interpret
383
- // a mid-read close as "Connection closed" rather than clean EOF.
384
- // queueMicrotask fires at the end of the current microtask
385
- // checkpoint — after start() and createFromReadableStream
386
- // initialization but before any macrotask, giving React a
387
- // consistent close signal. See TIM-524.
388
- queueMicrotask(onDOMContentLoaded);
389
- }
390
-
391
- const element = createFromReadableStream(rscPayload);
392
- initialElement = element;
393
-
394
- // ── Pre-hydration bootstrap sequence ──────────────────────────────
395
- //
396
- // These steps MUST execute in this exact order before hydrateRoot():
397
- //
398
- // 1. initRouter() — creates the global router so useRouter()
399
- // works during render (methods lazily resolve
400
- // the router at invocation, not render time,
401
- // but initRouter must still run first)
402
- //
403
- // 2. setCurrentParams() — populates module-level params snapshot so
404
- // + setNavigationState() useSegmentParams() and usePathname()
405
- // return correct values during hydration
406
- //
407
- // 3. hydrateRoot() — synchronously executes component render
408
- // functions that depend on steps 1-2
409
- //
410
- // Implicit prerequisite: the __timber_f RSC stream (ReadableStream
411
- // above) must be wired up before hydrateRoot, because React starts
412
- // consuming it synchronously during hydration.
413
- //
414
- // See design/19-client-navigation.md §"NavigationContext"
415
- runPreHydration(element);
416
-
417
- // Hydrate on document — the root layout renders the full <html> tree,
418
- // so React owns the entire document from the root.
419
- // Wrap with NavigationProvider (for atomic useParams/usePathname),
420
- // TimberNuqsAdapter (for nuqs context), and NavigationRoot (for
421
- // transition-based rendering during client navigation).
422
- //
423
- // NavigationRoot holds the element in React state and updates via
424
- // startTransition, so React keeps old UI visible while new Suspense
425
- // boundaries resolve during navigation. See design/05-streaming.md.
426
- const navState = getNavigationState();
427
- const withNav = createElement(
428
- NavigationProvider,
429
- { value: navState },
430
- element as React.ReactNode
431
- );
432
- const wrapped = createElement(TimberNuqsAdapter, null, withNav);
433
- const rootElement = createElement(NavigationRoot, {
434
- initial: wrapped,
435
- topLoaderConfig: _config.topLoader,
436
- });
437
-
438
- if (process.env.NODE_ENV !== 'production') {
439
- if (!getRouterOrNull()) {
440
- throw new Error(
441
- '[timber] hydrateRoot called before initRouter() — bootstrap order violated'
442
- );
443
- }
444
- }
445
-
446
- _reactRoot = hydrateRoot(document, rootElement, {
447
- // Suppress recoverable hydration errors from deny/error signals
448
- // inside Suspense boundaries. The server already handled these
449
- // (wrapStreamWithErrorHandling closes the stream cleanly after
450
- // the shell is flushed). React replays the error during hydration
451
- // but the server HTML is already correct — no recovery needed.
452
- onRecoverableError(error: unknown) {
453
- // Suppress errors during page unload (refresh/navigate away).
454
- // The aborted stream causes incomplete HTML which React flags
455
- // as a recoverable error — but the page is being replaced.
456
- if (isPageUnloading()) return;
457
- // Only log in dev — in production these are expected for
458
- // deny() inside Suspense and streaming error boundaries.
459
- if (process.env.NODE_ENV === 'development') {
460
- console.debug('[timber] Hydration recoverable error:', error);
461
- }
462
- },
463
- });
464
- } else {
465
- // No RSC payload available — create a non-hydrated root so client
466
- // navigation can still render RSC payloads. The initial SSR HTML
467
- // remains as-is; the first client navigation will replace it with
468
- // a React-managed tree.
469
- runPreHydration(null);
470
- // Defer React root creation until first client navigation (TIM-600).
471
- //
472
- // We must NOT call createRoot(document).render() here — that would take
473
- // React ownership of the entire document and blank the SSR HTML.
474
- // Instead, installDeferredNavigation sets up one-shot callbacks so the
475
- // first navigateTransition/transitionRender call creates the root on
476
- // `document` with the navigated content. After that initial render,
477
- // NavigationRoot's real startTransition-based callbacks take over.
478
- //
479
- // This also fixes TIM-580 (navigation from SSR-only pages) because the
480
- // deferred callbacks ensure NavigationRoot is mounted before the first
481
- // navigation completes.
482
- installDeferredNavigation((initial) => {
483
- const rootElement = createElement(NavigationRoot, {
484
- initial,
485
- topLoaderConfig: _config.topLoader,
486
- });
487
- _reactRoot = createRoot(document);
488
- _reactRoot.render(rootElement);
489
- });
490
- }
491
-
492
- // ── Router initialization (hoisted above hydrateRoot) ────────────────
493
- // Extracted into a function so both the hydration and createRoot paths
494
- // can call it. Must run before hydrateRoot so useRouter() works during
495
- // the initial render. renderRoot uses transitionRender which is set
496
- // by the NavigationRoot component during hydration.
497
- function initRouter(): void {
498
- // Feature-detect Navigation API. When available, the navigate event
499
- // replaces popstate for back/forward and catches external navigations.
500
- // See design/19-client-navigation.md §"Navigation API Integration"
501
- const useNavApi = hasNavigationApi();
502
-
503
- const deps: RouterDeps = {
504
- fetch: (url, init) => window.fetch(url, init),
505
- pushState: (data, unused, url) => window.history.pushState(data, unused, url),
506
- replaceState: (data, unused, url) => window.history.replaceState(data, unused, url),
507
- navigationApiActive: useNavApi,
508
- scrollTo: (x, y) => {
509
- // Scroll the document viewport.
510
- window.scrollTo(x, y);
511
- document.documentElement.scrollTop = y;
512
- document.body.scrollTop = y;
513
- // Scroll any element explicitly marked as a scroll container.
514
- // With segment tree merging, shared layouts (sidebars, nav bars)
515
- // are reconciled in place via cloneElement — React preserves their
516
- // DOM and scroll state naturally. We no longer auto-detect overflow
517
- // containers, which previously found the wrong element (e.g.,
518
- // scrolling a sidebar instead of the main content area).
519
- // Use `data-timber-scroll-restoration` to opt in specific containers.
520
- for (const el of document.querySelectorAll('[data-timber-scroll-restoration]')) {
521
- (el as HTMLElement).scrollTop = y;
522
- }
523
- },
524
- getCurrentUrl: () => window.location.pathname + window.location.search,
525
- getScrollY,
526
-
527
- // Decode RSC Flight stream using createFromFetch.
528
- // createFromFetch takes a Promise<Response> and progressively
529
- // parses the RSC stream as chunks arrive.
530
- //
531
- // Wrapped with stale client reference detection: if the server
532
- // has been redeployed with new bundles, the RSC payload may
533
- // reference module IDs that don't exist in the old client bundle.
534
- // We catch "Could not find the module" errors and trigger a full
535
- // page reload so the browser fetches the new bundle.
536
- decodeRsc: async (fetchPromise: Promise<Response>) => {
537
- try {
538
- return await createFromFetch(fetchPromise);
539
- } catch (error) {
540
- if (isStaleClientReference(error)) {
541
- triggerStaleReload();
542
- // Return a never-resolving promise to prevent further processing
543
- // while the page is reloading.
544
- return new Promise(() => {});
545
- }
546
- throw error;
547
- }
548
- },
549
-
550
- // Render decoded RSC tree via NavigationRoot's state-based mechanism.
551
- // Used for non-navigation renders (popstate cached replay, applyRevalidation).
552
- // Wraps with NavigationProvider + TimberNuqsAdapter.
553
- //
554
- // For navigation renders (navigate, refresh, popstate-with-fetch),
555
- // navigateTransition is used instead — it wraps the entire navigation
556
- // in a React transition with useOptimistic for the pending URL.
557
- //
558
- // navState is passed explicitly by the router — no temporal coupling
559
- // with getNavigationState().
560
- renderRoot: (element: unknown, navState: NavigationState) => {
561
- const withNav = createElement(
562
- NavigationProvider,
563
- { value: navState },
564
- element as React.ReactNode
565
- );
566
- const wrapped = createElement(TimberNuqsAdapter, null, withNav);
567
- transitionRender(wrapped);
568
- },
569
-
570
- // Run a navigation inside a React transition with optimistic pending URL.
571
- // The entire fetch + state update runs inside startTransition. useOptimistic
572
- // shows the pending URL immediately and reverts to null when the transition
573
- // commits (atomic with the new tree + params).
574
- //
575
- // The perform callback receives a wrapPayload function that wraps the
576
- // decoded RSC payload with NavigationProvider + NuqsAdapter. navState
577
- // is passed explicitly by the router — no getNavigationState() needed.
578
- navigateTransition: (pendingUrl: string, perform) => {
579
- return navigateTransition(pendingUrl, async () => {
580
- const payload = await perform((rawPayload: unknown, navState: NavigationState) => {
581
- const withNav = createElement(
582
- NavigationProvider,
583
- { value: navState },
584
- rawPayload as React.ReactNode
585
- );
586
- return createElement(TimberNuqsAdapter, null, withNav);
587
- });
588
- return payload as React.ReactNode;
589
- });
590
- },
591
-
592
- // Schedule a callback after the next paint so scroll operations
593
- // happen after React commits the new content to the DOM.
594
- // Double-rAF ensures the browser has painted the new frame.
595
- afterPaint: (callback: () => void) => {
596
- requestAnimationFrame(() => {
597
- requestAnimationFrame(callback);
598
- });
599
- },
600
-
601
- // Apply resolved head elements (title, meta tags) to the DOM after
602
- // SPA navigation. See design/16-metadata.md.
603
- applyHead: applyHeadElements,
604
- };
605
-
606
- router = createRouter(deps);
607
- setGlobalRouter(router);
608
-
609
- // Set up Navigation API integration after router is created.
610
- // The navigate event listener delegates to router.navigate and
611
- // router.handlePopState for external navigations and traversals.
612
- if (useNavApi) {
613
- navApiController = setupNavigationApi({
614
- onExternalNavigate: async (url, { replace, signal, scroll }) => {
615
- // Navigation intercepted by the Navigation API. Covers both
616
- // Link <a> clicks (user-initiated) and external navigations.
617
- // The Navigation API handles the URL update via intercept(),
618
- // so pass _skipHistory to avoid double pushState.
619
- await router.navigate(url, {
620
- replace,
621
- scroll,
622
- _signal: signal,
623
- _skipHistory: true,
624
- });
625
- },
626
- onTraverse: async (url, scrollY, signal) => {
627
- // Back/forward — delegate to the router's popstate handler.
628
- await router.handlePopState(url, scrollY, signal);
629
- },
630
- });
631
-
632
- // Wire the router-navigating flag into RouterDeps.
633
- // This must be done after setupNavigationApi returns the controller.
634
- deps.setRouterNavigating = (v) => navApiController!.setRouterNavigating(v);
635
- deps.saveNavigationEntryScroll = (y) => navApiController!.saveScrollPosition(y);
636
- deps.completeRouterNavigation = () => navApiController!.completeRouterNavigation();
637
- deps.navigationNavigate = (url, replace) => navApiController!.navigate(url, replace);
638
- }
639
- }
640
-
641
- // ── Pre-hydration sequence ──────────────────────────────────────────
642
- // Concentrates the ordering contract: initRouter → setParams/navState.
643
- // Called before hydrateRoot in the hydration path. The createRoot path
644
- // calls initRouter() directly (no params to read from server embed).
645
- function runPreHydration(_element: unknown): void {
646
- // Step 1: Initialize the router
647
- initRouter();
648
-
649
- // Step 2: Read server-embedded params and set navigation state
650
- const earlyParams = (self as unknown as Record<string, unknown>).__timber_params;
651
- if (earlyParams && typeof earlyParams === 'object') {
652
- setCurrentParams(earlyParams as Record<string, string | string[]>);
653
- setNavigationState({
654
- params: earlyParams as Record<string, string | string[]>,
655
- pathname: window.location.pathname,
656
- });
657
- delete (self as unknown as Record<string, unknown>).__timber_params;
658
- } else {
659
- setNavigationState({
660
- params: {},
661
- pathname: window.location.pathname,
662
- });
663
- }
664
- }
665
-
666
- // Store the initial page in the history stack so back-button works
667
- // after the first navigation. We store the decoded RSC element so
668
- // back navigation can replay it instantly without a server fetch.
669
- router.historyStack.push(window.location.pathname + window.location.search, {
670
- payload: initialElement,
671
- headElements: null, // SSR already set the correct head
672
- });
673
-
674
- // Initialize scroll state for the initial entry.
675
- // When Navigation API is available, use per-entry state.
676
- // Otherwise fall back to history.state.
677
- // Note: navApiController is assigned inside initRouter() which runs
678
- // synchronously before this point via runPreHydration().
679
- const navApi = navApiController as NavigationApiController | null;
680
- if (navApi) {
681
- navApi.saveScrollPosition(0);
682
- } else {
683
- window.history.replaceState({ timber: true, scrollY: 0 }, '');
684
- }
685
-
686
- // Populate the segment cache from server-embedded segment metadata.
687
- // This enables state tree diffing from the very first client navigation.
688
- // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
689
- const timberSegments = (self as unknown as Record<string, unknown>).__timber_segments;
690
- if (Array.isArray(timberSegments)) {
691
- router.initSegmentCache(timberSegments);
692
- delete (self as unknown as Record<string, unknown>).__timber_segments;
693
- }
694
-
695
- // NOTE: We do NOT cache segment elements from the initial RSC payload here.
696
- // The decoded element from createFromReadableStream is a thenable/lazy
697
- // element that React resolves during render — the segment walker can't
698
- // traverse it. The element cache is populated lazily after the first SPA
699
- // navigation (via mergeAndCachePayload in renderViaTransition), when
700
- // the decoded payload is a fully resolved React element tree.
701
-
702
- // Note: __timber_params is read before hydrateRoot (see above) so that
703
- // NavigationProvider has correct values during hydration. If the hydration
704
- // path was skipped (no RSC payload), populate the fallback here.
705
- const lateTimberParams = (self as unknown as Record<string, unknown>).__timber_params;
706
- if (lateTimberParams && typeof lateTimberParams === 'object') {
707
- setCurrentParams(lateTimberParams as Record<string, string | string[]>);
708
- setNavigationState({
709
- params: lateTimberParams as Record<string, string | string[]>,
710
- pathname: window.location.pathname,
711
- });
712
- delete (self as unknown as Record<string, unknown>).__timber_params;
713
- }
714
-
715
- // Register popstate handler for back/forward navigation.
716
- // When Navigation API is active, the navigate event covers traversals —
717
- // popstate is a no-op. When unavailable, popstate handles back/forward.
718
- //
719
- // Use pathname+search (not full href) to match the URL format used by
720
- // navigate() — Link hrefs are relative paths like "/scroll-test/page-a".
721
- // Read scrollY from history.state — the browser maintains per-entry state
722
- // so duplicate URLs in history each have their own scroll position.
723
- window.addEventListener('popstate', () => {
724
- // Navigation API handles traversals via the navigate event.
725
- if (navApiController) return;
726
-
727
- const state = window.history.state;
728
- const scrollY = state && typeof state.scrollY === 'number' ? state.scrollY : 0;
729
- void router.handlePopState(window.location.pathname + window.location.search, scrollY);
730
- });
731
-
732
- // Keep scroll position up to date as the user scrolls.
733
- // This ensures that when the user presses back/forward, the departing
734
- // page's scroll position is already saved in its history entry.
735
- // When Navigation API is available, uses per-entry state via
736
- // navigation.updateCurrentEntry(). Otherwise falls back to history.state.
737
- // Debounced to avoid excessive state updates during smooth scrolling.
738
- let scrollTimer: ReturnType<typeof setTimeout>;
739
- function saveScrollPosition(): void {
740
- clearTimeout(scrollTimer);
741
- scrollTimer = setTimeout(() => {
742
- const y = getScrollY();
743
- if (navApiController) {
744
- navApiController.saveScrollPosition(y);
745
- } else {
746
- const state = window.history.state;
747
- if (state && typeof state === 'object') {
748
- window.history.replaceState({ ...state, scrollY: y }, '');
749
- }
750
- }
751
- }, 100);
752
- }
753
- window.addEventListener('scroll', saveScrollPosition, { passive: true });
754
-
755
- // Link click and hover prefetch are handled per-component by Link's
756
- // onClick and onMouseEnter handlers. No global delegation needed.
757
- // See LOCAL-340.
758
-
759
- // Dev-only: Listen for RSC module invalidation events from @vitejs/plugin-rsc.
760
- // When a server component is edited, the RSC plugin sends an "rsc:update"
761
- // event. We trigger a router.refresh() to re-fetch the RSC payload with
762
- // the updated server code. This avoids a full page reload while still
763
- // picking up server-side changes.
764
- // See design/21-dev-server.md §"HMR Wiring"
765
- // Vite injects import.meta.hot in dev mode. Cast to access it without
766
- // requiring vite/client types in the package tsconfig.
767
- const hot = (
768
- import.meta as unknown as {
769
- hot?: {
770
- on(event: string, cb: (...args: unknown[]) => void): void;
771
- send(event: string, data: unknown): void;
772
- };
773
- }
774
- ).hot;
775
- if (hot) {
776
- hot.on('rsc:update', () => {
777
- void router.refresh();
778
- });
779
-
780
- // Listen for dev warnings forwarded from the server via WebSocket.
781
- // See dev-warnings.ts — emitOnce() sends these via server.hot.send().
782
- hot.on('timber:dev-warning', (data: unknown) => {
783
- const warning = data as { level: string; message: string };
784
- if (warning.level === 'error') {
785
- console.error(warning.message);
786
- } else {
787
- console.warn(warning.message);
788
- }
789
- });
790
-
791
- // Listen for server console logs forwarded via WebSocket.
792
- // Replays them in the browser console with a [SERVER] prefix
793
- // so developers can see server output without switching to the terminal.
794
- // See plugins/dev-logs.ts.
795
- setupServerLogReplay(hot);
796
-
797
- // Forward uncaught client errors to the server for the dev overlay.
798
- // The server source-maps the stack and sends it back via Vite's
799
- // error overlay protocol. See dev-server.ts §client error listener.
800
- setupClientErrorForwarding(hot);
801
- }
802
- }
803
-
804
- bootstrap(config);
805
-
806
- // Clear the stale reload flag on successful bootstrap. If the page
807
- // loaded and bootstrapped without hitting a stale reference error,
808
- // the loop guard should reset so the next stale error gets a fresh
809
- // reload attempt.
810
- clearStaleReloadFlag();
811
-
812
- // Global error handler for stale client reference errors during hydration.
813
- // The initial RSC payload is decoded lazily by React via createFromReadableStream.
814
- // If the payload references a module ID from a newer deployment, the error
815
- // surfaces as an unhandled rejection during React's render/hydration cycle.
816
- // This handler catches those errors and triggers a full page reload.
817
- //
818
- // Also catches chunk load failures (dynamic import of missing assets after
819
- // a deployment) — these surface as "Failed to fetch dynamically imported module"
820
- // or "Loading chunk <name> failed" errors. See TIM-446.
821
- window.addEventListener('unhandledrejection', (event) => {
822
- if (isStaleClientReference(event.reason) || isChunkLoadError(event.reason)) {
823
- event.preventDefault();
824
- triggerStaleReload();
825
- }
826
- });
827
-
828
- // Also catch synchronous errors from chunk loads (some browsers throw
829
- // TypeError synchronously instead of via unhandled rejection).
830
- window.addEventListener('error', (event) => {
831
- if (isChunkLoadError(event.error)) {
832
- event.preventDefault();
833
- triggerStaleReload();
834
- }
835
- });
836
-
837
- // Signal that the client runtime has been initialized.
838
- // Used by E2E tests to wait for hydration before interacting.
839
- // We append a <meta name="timber-ready"> tag rather than setting a
840
- // data attribute on <html>. Since React owns the entire document
841
- // via hydrateRoot(document, ...), mutating <html> attributes causes
842
- // hydration mismatch warnings. Dynamically-added <meta> tags don't
843
- // conflict because React doesn't reconcile them.
844
- const readyMeta = document.createElement('meta');
845
- readyMeta.name = 'timber-ready';
846
- document.head.appendChild(readyMeta);