@timber-js/app 0.2.0-alpha.210 → 0.2.0-alpha.212

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 (158) hide show
  1. package/agent-skill.md +10 -5
  2. package/dist/_chunks/{actions-Rjk4htmA.js → actions-CCdnVtWm.js} +8 -6
  3. package/dist/_chunks/actions-CCdnVtWm.js.map +1 -0
  4. package/dist/_chunks/{als-registry-DaxkVjt5.js → als-registry-BZqHCtq-.js} +2 -4
  5. package/dist/_chunks/als-registry-BZqHCtq-.js.map +1 -0
  6. package/dist/_chunks/{cache-api-DGdYfNJn.js → cache-api-LA3sBpUS.js} +5 -5
  7. package/dist/_chunks/{cache-api-DGdYfNJn.js.map → cache-api-LA3sBpUS.js.map} +1 -1
  8. package/dist/_chunks/{chains-BoO51joc.js → chains-BfoPFraI.js} +5 -146
  9. package/dist/_chunks/chains-BfoPFraI.js.map +1 -0
  10. package/dist/_chunks/classify-BT66U83D.js +147 -0
  11. package/dist/_chunks/classify-BT66U83D.js.map +1 -0
  12. package/dist/_chunks/{cli-check-ajNY3B2e.js → cli-check-BfQ54-UJ.js} +3 -3
  13. package/dist/_chunks/{cli-check-ajNY3B2e.js.map → cli-check-BfQ54-UJ.js.map} +1 -1
  14. package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js → cli-schema-sync-czh2dsLs.js} +2 -2
  15. package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js.map → cli-schema-sync-czh2dsLs.js.map} +1 -1
  16. package/dist/_chunks/client-dep-entries-CQwpb8dI.js +66 -0
  17. package/dist/_chunks/client-dep-entries-CQwpb8dI.js.map +1 -0
  18. package/dist/_chunks/{convention-lint-DLmhGsRS.js → convention-lint-BEVW4EID.js} +3 -3
  19. package/dist/_chunks/{convention-lint-DLmhGsRS.js.map → convention-lint-BEVW4EID.js.map} +1 -1
  20. package/dist/_chunks/{dev-server-v97rQH4b.js → dev-server-FKxptbnI.js} +2 -2
  21. package/dist/_chunks/{dev-server-v97rQH4b.js.map → dev-server-FKxptbnI.js.map} +1 -1
  22. package/dist/_chunks/{json-lossy-check-CVuRs2hG.js → json-lossy-check-C8zBY2uZ.js} +2 -2
  23. package/dist/_chunks/{json-lossy-check-CVuRs2hG.js.map → json-lossy-check-C8zBY2uZ.js.map} +1 -1
  24. package/dist/_chunks/{live-graph-9cSnn_h9.js → live-graph-C_4v-fHv.js} +4 -3
  25. package/dist/_chunks/{live-graph-9cSnn_h9.js.map → live-graph-C_4v-fHv.js.map} +1 -1
  26. package/dist/_chunks/{logger-BP0LN6vP.js → logger-CbLdcy-W.js} +2 -2
  27. package/dist/_chunks/{logger-BP0LN6vP.js.map → logger-CbLdcy-W.js.map} +1 -1
  28. package/dist/_chunks/{poison-scan-vGV7Re0B.js → poison-scan-C92liMAr.js} +2 -4
  29. package/dist/_chunks/poison-scan-C92liMAr.js.map +1 -0
  30. package/dist/_chunks/{scanner-DmqdxzbW.js → scanner-CQt12vE2.js} +2 -2
  31. package/dist/_chunks/{scanner-DmqdxzbW.js.map → scanner-CQt12vE2.js.map} +1 -1
  32. package/dist/_chunks/{sizeof-BM1409x2.js → sizeof-QPE5nd3u.js} +2 -2
  33. package/dist/_chunks/{sizeof-BM1409x2.js.map → sizeof-QPE5nd3u.js.map} +1 -1
  34. package/dist/_chunks/{walkers-Czu2jXFq.js → walkers-DAT4avhZ.js} +3 -3
  35. package/dist/_chunks/{walkers-Czu2jXFq.js.map → walkers-DAT4avhZ.js.map} +1 -1
  36. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  37. package/dist/analyze/classify.d.ts +2 -0
  38. package/dist/analyze/classify.d.ts.map +1 -1
  39. package/dist/analyze/crawl-entry.js +3 -2
  40. package/dist/analyze/crawl-entry.js.map +1 -1
  41. package/dist/analyze/graph-command.js +2 -2
  42. package/dist/analyze/poison-scan.d.ts.map +1 -1
  43. package/dist/cache/index.js +2 -2
  44. package/dist/cache/stores/memory.js +1 -1
  45. package/dist/cdn/workers-cache-purge.js +1 -1
  46. package/dist/cli.js +3 -3
  47. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  48. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  49. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  50. package/dist/client/form.d.ts +17 -64
  51. package/dist/client/form.d.ts.map +1 -1
  52. package/dist/client/index.d.ts +2 -2
  53. package/dist/client/index.d.ts.map +1 -1
  54. package/dist/client/index.js +23 -51
  55. package/dist/client/index.js.map +1 -1
  56. package/dist/client/internal.js +25 -25
  57. package/dist/client/internal.js.map +1 -1
  58. package/dist/client/navigation-api.d.ts +25 -63
  59. package/dist/client/navigation-api.d.ts.map +1 -1
  60. package/dist/client/router-lifecycle.d.ts +17 -11
  61. package/dist/client/router-lifecycle.d.ts.map +1 -1
  62. package/dist/client/router-pipeline.d.ts +0 -1
  63. package/dist/client/router-pipeline.d.ts.map +1 -1
  64. package/dist/client/router-types.d.ts +14 -45
  65. package/dist/client/router-types.d.ts.map +1 -1
  66. package/dist/client/router.d.ts.map +1 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +44 -7
  69. package/dist/index.js.map +1 -1
  70. package/dist/plugins/client-dep-entries.d.ts +44 -0
  71. package/dist/plugins/client-dep-entries.d.ts.map +1 -0
  72. package/dist/plugins/content.d.ts.map +1 -1
  73. package/dist/plugins/entries.d.ts +6 -0
  74. package/dist/plugins/entries.d.ts.map +1 -1
  75. package/dist/plugins/shims.d.ts.map +1 -1
  76. package/dist/routing/index.js +2 -2
  77. package/dist/rsc-runtime/rsc.d.ts +1 -1
  78. package/dist/rsc-runtime/rsc.d.ts.map +1 -1
  79. package/dist/server/action-client.d.ts +9 -5
  80. package/dist/server/action-client.d.ts.map +1 -1
  81. package/dist/server/action-handler.d.ts +27 -8
  82. package/dist/server/action-handler.d.ts.map +1 -1
  83. package/dist/server/als-registry.d.ts +22 -2
  84. package/dist/server/als-registry.d.ts.map +1 -1
  85. package/dist/server/client-error-message.d.ts +12 -0
  86. package/dist/server/client-error-message.d.ts.map +1 -0
  87. package/dist/server/form-state-embed.d.ts +32 -0
  88. package/dist/server/form-state-embed.d.ts.map +1 -0
  89. package/dist/server/index.d.ts +0 -2
  90. package/dist/server/index.d.ts.map +1 -1
  91. package/dist/server/index.js +7 -40
  92. package/dist/server/index.js.map +1 -1
  93. package/dist/server/internal.js +10 -6
  94. package/dist/server/internal.js.map +1 -1
  95. package/dist/server/logger.d.ts +1 -0
  96. package/dist/server/logger.d.ts.map +1 -1
  97. package/dist/server/pipeline.d.ts +20 -6
  98. package/dist/server/pipeline.d.ts.map +1 -1
  99. package/dist/server/request-context.d.ts +27 -2
  100. package/dist/server/request-context.d.ts.map +1 -1
  101. package/dist/server/route-element-builder.d.ts.map +1 -1
  102. package/dist/server/rsc-entry/action-dispatcher.d.ts +6 -5
  103. package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
  104. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  105. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  106. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  107. package/dist/server/ssr-bridge-types.d.ts +8 -0
  108. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  109. package/dist/server/ssr-entry.d.ts.map +1 -1
  110. package/dist/server/ssr-render.d.ts +3 -0
  111. package/dist/server/ssr-render.d.ts.map +1 -1
  112. package/docs/api/31-api-client.mdx +9 -3
  113. package/docs/learn/08-forms-and-actions.mdx +21 -28
  114. package/package.json +1 -1
  115. package/src/analyze/classify.ts +1 -1
  116. package/src/analyze/poison-scan.ts +1 -4
  117. package/src/client/browser-entry/action-dispatch.ts +74 -18
  118. package/src/client/browser-entry/hydrate.ts +18 -0
  119. package/src/client/browser-entry/router-init.ts +4 -18
  120. package/src/client/form.tsx +33 -98
  121. package/src/client/index.ts +2 -2
  122. package/src/client/navigation-api.ts +47 -173
  123. package/src/client/navigation-transition.ts +2 -2
  124. package/src/client/router-lifecycle.ts +40 -20
  125. package/src/client/router-pipeline.ts +3 -9
  126. package/src/client/router-types.ts +14 -49
  127. package/src/client/router.ts +38 -58
  128. package/src/index.ts +21 -2
  129. package/src/plugins/client-dep-entries.ts +78 -0
  130. package/src/plugins/content.ts +26 -1
  131. package/src/plugins/entries.ts +7 -0
  132. package/src/plugins/shims.ts +0 -2
  133. package/src/rsc-runtime/rsc.ts +3 -0
  134. package/src/rsc-runtime/vendor-types.d.ts +14 -0
  135. package/src/server/action-client.ts +18 -20
  136. package/src/server/action-handler.ts +133 -95
  137. package/src/server/als-registry.ts +23 -9
  138. package/src/server/client-error-message.ts +18 -0
  139. package/src/server/form-state-embed.ts +63 -0
  140. package/src/server/index.ts +0 -4
  141. package/src/server/logger.ts +6 -1
  142. package/src/server/pipeline.ts +27 -8
  143. package/src/server/request-context.ts +39 -2
  144. package/src/server/route-element-builder.ts +12 -1
  145. package/src/server/rsc-entry/action-dispatcher.ts +37 -34
  146. package/src/server/rsc-entry/error-renderer.ts +2 -1
  147. package/src/server/rsc-entry/rsc-stream.ts +20 -11
  148. package/src/server/rsc-entry/ssr-renderer.ts +14 -1
  149. package/src/server/ssr-bridge-types.ts +9 -0
  150. package/src/server/ssr-entry.ts +1 -0
  151. package/src/server/ssr-render.ts +5 -0
  152. package/dist/_chunks/actions-Rjk4htmA.js.map +0 -1
  153. package/dist/_chunks/als-registry-DaxkVjt5.js.map +0 -1
  154. package/dist/_chunks/chains-BoO51joc.js.map +0 -1
  155. package/dist/_chunks/poison-scan-vGV7Re0B.js.map +0 -1
  156. package/dist/server/form-flash.d.ts +0 -78
  157. package/dist/server/form-flash.d.ts.map +0 -1
  158. package/src/server/form-flash.ts +0 -89
@@ -35,23 +35,13 @@ export interface NavigationEpoch {
35
35
  readonly idle: boolean;
36
36
  }
37
37
 
38
- /** The subset of `RouterDeps` the navigation lifecycle needs. */
39
- export interface NavigationLifecycleDeps {
40
- /**
41
- * Signal that a router-initiated navigation has completed. Resolves the
42
- * deferred promise that ties the browser's native loading state to the
43
- * navigation lifecycle.
44
- */
45
- completeRouterNavigation?: () => void;
46
- }
47
-
48
38
  export interface NavigationLifecycle {
49
39
  /** The owner that holds the router right now, or null when idle. */
50
40
  currentOwner: () => RenderOwner | null;
51
41
  /**
52
42
  * Take ownership of the router, superseding whatever held it. Returns
53
43
  * a fresh `RenderOwner` for the new navigation. See the implementation
54
- * for the three parts of superseding.
44
+ * for the two parts of superseding.
55
45
  */
56
46
  createNavOwner: (
57
47
  kind: 'navigation' | 'revalidation',
@@ -99,6 +89,21 @@ export interface NavigationLifecycle {
99
89
  * replaces an earlier one (TIM-1474).
100
90
  */
101
91
  runWhenIdle: (task: () => void) => void;
92
+ /**
93
+ * Subscribe to the router becoming idle (pending false AND commit done):
94
+ * `listener` runs on every such transition until unsubscribed. Unlike
95
+ * `runWhenIdle`, any number of listeners may wait.
96
+ */
97
+ onIdle: (listener: () => void) => () => void;
98
+ /**
99
+ * Subscribe to React committing a navigation's tree — the tree on screen
100
+ * has changed, whether or not its stream has finished decoding (so the
101
+ * router may still be pending). Runs on every commit until unsubscribed,
102
+ * with the committing navigation's `epoch().seq`: a tree handed to React
103
+ * before a later navigation started can still commit after it, and the
104
+ * sequence says which one it was.
105
+ */
106
+ onTreeCommit: (listener: (seq: number) => void) => () => void;
102
107
  /**
103
108
  * Settle any handed-off-but-uncommitted owners as superseded, then flush
104
109
  * the idle task if the router is now idle. Called by `syncShallowSearch`
@@ -119,7 +124,7 @@ function isAbortError(error: unknown): boolean {
119
124
  return false;
120
125
  }
121
126
 
122
- export function createNavigationLifecycle(deps: NavigationLifecycleDeps): NavigationLifecycle {
127
+ export function createNavigationLifecycle(): NavigationLifecycle {
123
128
  // The single ownership slot. One navigation at a time.
124
129
  let current: RenderOwner | null = null;
125
130
 
@@ -135,6 +140,10 @@ export function createNavigationLifecycle(deps: NavigationLifecycleDeps): Naviga
135
140
 
136
141
  let navigationSeq = 0;
137
142
  let idleTask: (() => void) | null = null;
143
+ const idleListeners = new Set<() => void>();
144
+ const treeCommitListeners = new Set<(seq: number) => void>();
145
+ // The navigationSeq each owner ran under, for tree-commit listeners.
146
+ const ownerSeq = new WeakMap<RenderOwner, number>();
138
147
 
139
148
  /**
140
149
  * Whether a handed-off tree has not yet committed. Derived from the slot:
@@ -167,17 +176,13 @@ export function createNavigationLifecycle(deps: NavigationLifecycleDeps): Naviga
167
176
  * in-flight navigation. Optionally links to an external signal (e.g.,
168
177
  * from the Navigation API's NavigateEvent.signal).
169
178
  *
170
- * Superseding is one operation with three parts:
179
+ * Superseding is one operation with two parts:
171
180
  * 1. Abort the previous navigation's fetch — UNLESS its tree is the one on
172
181
  * screen, in which case tearing the stream down would error the page the
173
182
  * user is currently looking at. See `RenderOwner.handedOff`.
174
183
  * 2. Settle the previous owner as 'superseded' so its transition detects
175
184
  * it lost and never hands a stale tree to React. This replaces both
176
185
  * `supersedeNavigationTransitions()` and the transition counter bump.
177
- * 3. Resolve its Navigation API deferred — the superseded navigation's
178
- * finally block is staleness-guarded (see TIM-1034) and no longer
179
- * cleans up after itself, so the browser's native loading state for
180
- * the dead navigation is cleared here.
181
186
  */
182
187
  function createNavOwner(
183
188
  kind: 'navigation' | 'revalidation',
@@ -186,7 +191,6 @@ export function createNavigationLifecycle(deps: NavigationLifecycleDeps): Naviga
186
191
  if (current) {
187
192
  abortUnlessHandedOff(current);
188
193
  current.settle('superseded');
189
- deps.completeRouterNavigation?.();
190
194
  }
191
195
  // A handed-off owner whose runNavigation has already finished but whose
192
196
  // tree hasn't committed yet. Settle it so its onCommit fires.
@@ -196,6 +200,7 @@ export function createNavigationLifecycle(deps: NavigationLifecycleDeps): Naviga
196
200
  }
197
201
  navigationSeq += 1;
198
202
  const owner = createRenderOwner(kind);
203
+ ownerSeq.set(owner, navigationSeq);
199
204
  current = owner;
200
205
 
201
206
  // If an external signal is provided (e.g., Navigation API),
@@ -264,14 +269,16 @@ export function createNavigationLifecycle(deps: NavigationLifecycleDeps): Naviga
264
269
  if (current === owner) {
265
270
  current = null;
266
271
  setPending(false);
267
- deps.completeRouterNavigation?.();
268
272
  flushIdleTask();
269
273
  }
270
274
  }
271
275
  }
272
276
 
277
+ // Every place the router may have just become idle calls this.
273
278
  function flushIdleTask(): void {
274
- if (routerPhase.phase !== 'idle' || hasUncommittedNav() || !idleTask) return;
279
+ if (routerPhase.phase !== 'idle' || hasUncommittedNav()) return;
280
+ for (const listener of [...idleListeners]) listener();
281
+ if (!idleTask) return;
275
282
  const task = idleTask;
276
283
  idleTask = null;
277
284
  task();
@@ -307,10 +314,13 @@ export function createNavigationLifecycle(deps: NavigationLifecycleDeps): Naviga
307
314
  if (pendingCommit === owner) {
308
315
  pendingCommit = null;
309
316
  }
317
+ const seq = ownerSeq.get(owner) ?? navigationSeq;
318
+ for (const listener of [...treeCommitListeners]) listener(seq);
310
319
  flushIdleTask();
311
320
  },
312
321
 
313
322
  placeRevalidationOwner(owner: RenderOwner): void {
323
+ ownerSeq.set(owner, navigationSeq);
314
324
  current = owner;
315
325
  },
316
326
 
@@ -340,6 +350,16 @@ export function createNavigationLifecycle(deps: NavigationLifecycleDeps): Naviga
340
350
  idleTask = task;
341
351
  },
342
352
 
353
+ onIdle(listener) {
354
+ idleListeners.add(listener);
355
+ return () => idleListeners.delete(listener);
356
+ },
357
+
358
+ onTreeCommit(listener) {
359
+ treeCommitListeners.add(listener);
360
+ return () => treeCommitListeners.delete(listener);
361
+ },
362
+
343
363
  settleHandoffs(): void {
344
364
  if (pendingCommit) {
345
365
  pendingCommit.settle('superseded');
@@ -55,7 +55,6 @@ export interface NavigationFetchOptions {
55
55
  replace: boolean;
56
56
  commitUrl?: string;
57
57
  signal?: AbortSignal;
58
- skipHistory?: boolean;
59
58
  departingUrl?: string;
60
59
  }
61
60
 
@@ -388,9 +387,9 @@ export function createNavigationPipeline({
388
387
  result = await fetchRscPayload(url, deps, freshStateTree, currentUrl, options.signal);
389
388
  }
390
389
 
391
- // Update the browser history — skip when the Navigation API has already
392
- // updated the URL via event.intercept() (external navigations).
393
- // The committed URL keeps the #fragment (commitUrl) even though the
390
+ // Update the browser history. This is the only place a push/replace
391
+ // navigation moves the address bar, with or without the Navigation API
392
+ // (its listener leaves same-document pushState alone). The committed URL keeps the #fragment (commitUrl) even though the
394
393
  // fetch/history-stack URL is hash-less (TIM-1035).
395
394
  //
396
395
  // Deferred with the rest of the commit: the address bar is navigation
@@ -400,17 +399,12 @@ export function createNavigationPipeline({
400
399
  // response that won the race anyway re-pushed the abandoned URL
401
400
  // (TIM-1022's failure mode, made unconditional by TIM-1301).
402
401
  const commitHistoryUrl = (): void => {
403
- if (options.skipHistory) return;
404
402
  const commitUrl = options.commitUrl ?? url;
405
- // Set the router-navigating flag so the Navigation API's navigate
406
- // listener doesn't double-intercept this pushState/replaceState.
407
- deps.setRouterNavigating?.(true);
408
403
  if (options.replace) {
409
404
  deps.replaceState({ timber: true, scrollY: 0 }, '', commitUrl);
410
405
  } else {
411
406
  deps.pushState({ timber: true, scrollY: 0 }, '', commitUrl);
412
407
  }
413
- deps.setRouterNavigating?.(false);
414
408
  };
415
409
 
416
410
  const params = await result.params;
@@ -89,25 +89,6 @@ export interface NavigationOptions {
89
89
  * which of the three it was.
90
90
  */
91
91
  onCommit?: (outcome: CommitOutcome) => void;
92
- /**
93
- * @internal AbortSignal from the Navigation API's NavigateEvent.
94
- * When provided, the signal is linked to the router's per-navigation
95
- * AbortController so in-flight RSC fetches are cancelled when a new
96
- * navigation starts.
97
- */
98
- _signal?: AbortSignal;
99
- /**
100
- * @internal Skip pushState/replaceState — the Navigation API has already
101
- * updated the URL via event.intercept(). Used for external navigations
102
- * intercepted by the navigate event handler.
103
- */
104
- _skipHistory?: boolean;
105
- /**
106
- * @internal The URL the user is navigating FROM, captured before the
107
- * Navigation API commits the destination. Sent as X-Timber-URL for
108
- * slot skip comparison on the server (TIM-1232).
109
- */
110
- _departingUrl?: string;
111
92
  }
112
93
 
113
94
  /**
@@ -177,34 +158,12 @@ export interface RouterDeps {
177
158
  onCommit?: (outcome: CommitOutcome) => void
178
159
  ) => Promise<void>;
179
160
 
180
- /**
181
- * Whether the Navigation API is active and handling traversals.
182
- * When true, the popstate handler is a no-op — the Navigation API's
183
- * navigate event covers back/forward button presses.
184
- */
185
- navigationApiActive?: boolean;
186
-
187
- /**
188
- * Called around pushState/replaceState to set a flag that prevents
189
- * the Navigation API's navigate listener from double-handling
190
- * router-initiated navigations.
191
- */
192
- setRouterNavigating?: (value: boolean) => void;
193
-
194
161
  /**
195
162
  * Save scroll position via the Navigation API's per-entry state.
196
163
  * When provided, used instead of history.replaceState for scroll storage.
197
164
  */
198
165
  saveNavigationEntryScroll?: (scrollY: number) => void;
199
166
 
200
- /**
201
- * Signal that a router-initiated navigation has completed. Resolves the
202
- * deferred promise that ties the browser's native loading state to the
203
- * navigation lifecycle. Called in the finally block of navigate/refresh,
204
- * the same place `setPending(false)` clears the TopLoader.
205
- */
206
- completeRouterNavigation?: () => void;
207
-
208
167
  /**
209
168
  * Get the current unwrapped RSC payload element. Used for partial
210
169
  * navigation: the router re-wraps the same element with new
@@ -212,14 +171,6 @@ export interface RouterDeps {
212
171
  */
213
172
  _getCurrentPayload?: () => unknown;
214
173
 
215
- /**
216
- * Initiate a navigation via the Navigation API (`navigation.navigate()`).
217
- * Fires the navigate event BEFORE committing the URL, allowing Chrome
218
- * to show its native loading indicator. Falls back to pushState when
219
- * unavailable.
220
- */
221
- navigationNavigate?: (url: string, replace: boolean) => void;
222
-
223
174
  /**
224
175
  * Scroll the element matching a URL #fragment into view. Returns true
225
176
  * when a matching element was found and scrolled. When absent or false,
@@ -301,6 +252,20 @@ export interface RouterInstance {
301
252
  * router-idle are true (TIM-1474).
302
253
  */
303
254
  runWhenIdle(task: () => void): void;
255
+ /**
256
+ * Subscribe to the router becoming idle — no navigation pending and no
257
+ * handed-off tree waiting to commit. Runs on every such transition until
258
+ * the returned function unsubscribes; any number of listeners may wait.
259
+ */
260
+ onIdle(listener: () => void): () => void;
261
+ /**
262
+ * Subscribe to React committing a navigation's tree: the page on screen
263
+ * has changed, even if its stream is still decoding and the router is
264
+ * still pending. Runs on every commit until unsubscribed, with the
265
+ * committing navigation's `epoch().seq` — a tree handed to React before a
266
+ * later navigation started can still commit after it.
267
+ */
268
+ onTreeCommit(listener: (seq: number) => void): () => void;
304
269
  /**
305
270
  * Settle any handed-off-but-uncommitted owners as superseded, then flush
306
271
  * idle tasks. Called by `syncShallowSearch` when a shallow URL update
@@ -59,7 +59,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
59
59
 
60
60
  // Ownership of the router: who is navigating, whose fetch may be cut, and
61
61
  // the pending store TopLoader subscribes to. See router-lifecycle.ts.
62
- const lifecycle = createNavigationLifecycle(deps);
62
+ const lifecycle = createNavigationLifecycle();
63
63
  const {
64
64
  currentOwner,
65
65
  createNavOwner,
@@ -108,8 +108,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
108
108
  async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
109
109
  const scroll = options.scroll !== false;
110
110
  const replace = options.replace === true;
111
- const externalSignal = options._signal as AbortSignal | undefined;
112
- const skipHistory = options._skipHistory === true;
113
111
  // A push, a replace, and a server action redirect all move the user
114
112
  // forward; the caller's own types ride beside it. A redirect this router
115
113
  // follows keeps the types of the render it interrupted instead.
@@ -127,11 +125,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
127
125
  const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
128
126
  const fetchUrl = hashIndex === -1 ? url : url.slice(0, hashIndex);
129
127
 
130
- // Use the pre-intercept departing URL when the Navigation API has already
131
- // committed the destination (_departingUrl from navigation-api.ts). Otherwise
132
- // capture it now — getCurrentUrl() is still the departing URL at this point
133
- // for non-Navigation-API navigations (TIM-1232).
134
- const departingUrl = options._departingUrl ?? deps.getCurrentUrl();
128
+ // The address bar moves only when the destination commits, so it still
129
+ // names the page being left (TIM-1232).
130
+ const departingUrl = deps.getCurrentUrl();
135
131
 
136
132
  // Capture the departing page's scroll position for scroll={false} preservation.
137
133
  const currentScrollY = deps.getScrollY();
@@ -155,57 +151,39 @@ export function createRouter(deps: RouterDeps): RouterInstance {
155
151
  await leaveSpaSuperseding(url, departingUrl);
156
152
  }
157
153
 
158
- let effectiveSkipHistory = skipHistory;
159
-
160
- await runNavigation(
161
- url,
162
- async (owner) => {
163
- // When Navigation API is active, initiate the navigation via
164
- // navigation.navigate() BEFORE the fetch. Must happen after
165
- // createNavOwner supersedes the previous navigation (done by
166
- // runNavigation) so the old deferred is resolved first.
167
- if (!effectiveSkipHistory && deps.navigationNavigate) {
168
- deps.setRouterNavigating?.(true);
169
- deps.navigationNavigate(url, replace);
170
- deps.setRouterNavigating?.(false);
171
- effectiveSkipHistory = true;
172
- }
173
-
174
- try {
175
- await renderViaTransition(
176
- fetchUrl,
177
- owner,
178
- types,
179
- () =>
180
- performNavigationFetch(fetchUrl, {
181
- replace,
182
- commitUrl: url,
183
- signal: owner.fetchAbort.signal,
184
- skipHistory: effectiveSkipHistory,
185
- departingUrl,
186
- }),
187
- options.onCommit
188
- );
189
-
190
- // Scroll-to-top on forward navigation, scroll to the #fragment target
191
- // when the URL has one, or restore captured position for scroll={false}.
192
- if (scroll && hash) {
193
- scrollToHashAfterPaint(hash);
194
- } else {
195
- restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
196
- }
197
- } catch (error) {
198
- // `url` is where the user asked to go and `departingUrl` is where
199
- // they were: a failure here finishes the click as a full document
200
- // load of the destination, fragment included (TIM-1234). Reloading
201
- // instead would rebuild the page they were *leaving* and discard
202
- // the click (TIM-1275).
203
- if (await recoverFromNavigationError(error, owner, url, departingUrl, types)) return;
204
- throw error;
154
+ await runNavigation(url, async (owner) => {
155
+ try {
156
+ await renderViaTransition(
157
+ fetchUrl,
158
+ owner,
159
+ types,
160
+ () =>
161
+ performNavigationFetch(fetchUrl, {
162
+ replace,
163
+ commitUrl: url,
164
+ signal: owner.fetchAbort.signal,
165
+ departingUrl,
166
+ }),
167
+ options.onCommit
168
+ );
169
+
170
+ // Scroll-to-top on forward navigation, scroll to the #fragment target
171
+ // when the URL has one, or restore captured position for scroll={false}.
172
+ if (scroll && hash) {
173
+ scrollToHashAfterPaint(hash);
174
+ } else {
175
+ restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
205
176
  }
206
- },
207
- externalSignal
208
- );
177
+ } catch (error) {
178
+ // `url` is where the user asked to go and `departingUrl` is where
179
+ // they were: a failure here finishes the click as a full document
180
+ // load of the destination, fragment included (TIM-1234). Reloading
181
+ // instead would rebuild the page they were *leaving* and discard
182
+ // the click (TIM-1275).
183
+ if (await recoverFromNavigationError(error, owner, url, departingUrl, types)) return;
184
+ throw error;
185
+ }
186
+ });
209
187
  }
210
188
 
211
189
  /**
@@ -523,6 +501,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
523
501
  return outcome === 'committed';
524
502
  },
525
503
  runWhenIdle: (task: () => void) => lifecycle.runWhenIdle(task),
504
+ onIdle: (listener) => lifecycle.onIdle(listener),
505
+ onTreeCommit: (listener) => lifecycle.onTreeCommit(listener),
526
506
  settleHandoffs: () => lifecycle.settleHandoffs(),
527
507
  suppressSegmentReuse(): void {
528
508
  // TIM-1466: uses invalidateReuse() instead of clear() to preserve
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ import { createRequire } from 'node:module';
18
18
  import react from '@vitejs/plugin-react';
19
19
  import { timberContent } from './plugins/content.ts';
20
20
  import { timberDevServer } from './plugins/dev-server.ts';
21
- import { timberEntries } from './plugins/entries.ts';
21
+ import { timberEntries, BROWSER_ENTRY_FILE } from './plugins/entries.ts';
22
22
  import { timberMdx } from './plugins/mdx.ts';
23
23
  import { timberRouting } from './plugins/routing.ts';
24
24
  import { timberShims } from './plugins/shims.ts';
@@ -473,9 +473,28 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
473
473
  console: originalConsole as Console,
474
474
  });
475
475
 
476
+ // Seed the client optimizer's startup crawl with every 'use client'
477
+ // file so client-only packages are pre-bundled before the first
478
+ // request, not discovered mid-load (which full-reloads the page).
479
+ // Vite concatenates these with any entries the user sets.
480
+ // See design/21-dev-server.md §"Client Dependency Pre-Bundling".
481
+ let environments: Record<string, object> = envOutDirs;
482
+ if (!isPreview) {
483
+ ctx.timer.start('client-deps-scan');
484
+ // Loaded lazily: directive detection pulls in @vitejs/plugin-rsc's
485
+ // transforms, which builds and preview never need.
486
+ const { clientOptimizeDepsEntries } = await import('./plugins/client-dep-entries.ts');
487
+ const entries = await clientOptimizeDepsEntries(resolvedRoot, BROWSER_ENTRY_FILE, buildDir);
488
+ ctx.timer.end('client-deps-scan');
489
+ environments = {
490
+ ...envOutDirs,
491
+ client: { ...envOutDirs.client, optimizeDeps: { entries } },
492
+ };
493
+ }
494
+
476
495
  return {
477
496
  build: { outDir: buildOutDir },
478
- environments: envOutDirs,
497
+ environments,
479
498
  optimizeDeps: { exclude: timberOptimizeDepsExclude },
480
499
  customLogger,
481
500
  server: serverConfig,
@@ -0,0 +1,78 @@
1
+ /**
2
+ * client-dep-entries — seed the client dep optimizer's startup scan with
3
+ * the app's `'use client'` modules.
4
+ *
5
+ * Vite's dev optimizer discovers bare imports by crawling from its
6
+ * entries before the first request. With no `optimizeDeps.entries`, the
7
+ * client environment crawls its build input — timber's browser entry —
8
+ * and nothing more: every `'use client'` module is reached only through
9
+ * the RSC payload at runtime, never through a static import the crawl
10
+ * can follow. A package imported only from client components is then
11
+ * found on the first browser request, re-optimized mid-load, and the
12
+ * page full-reloads ("optimized dependencies changed. reloading"). That
13
+ * reload can leave react and react-dom from two optimizer runs in the
14
+ * browser (duplicate React → "Invalid hook call").
15
+ *
16
+ * Listing the `'use client'` files as entries lets the startup crawl see
17
+ * those imports. Setting `entries` replaces the build-input default, so
18
+ * the browser entry is listed too. Files that gain the directive after
19
+ * startup still fall back to Vite's late discovery.
20
+ *
21
+ * Design docs: 21-dev-server.md §"Client Dependency Pre-Bundling".
22
+ */
23
+
24
+ import { readFile } from 'node:fs/promises';
25
+ import { sep } from 'node:path';
26
+ import { detectDirective, PARSEABLE_EXTENSIONS } from '../analyze/classify.ts';
27
+ import { normalizeSlashes, scanProjectSourceFiles } from '../analyze/scan.ts';
28
+
29
+ /**
30
+ * Every project source file under `root` whose directive prologue holds
31
+ * `'use client'`. Uses the same walker as `timber graph` (node_modules,
32
+ * root-level dot dirs and build outputs skipped) and the same AST
33
+ * directive check the RSC plugin applies. Files under `excludeDir` (the
34
+ * build output) are skipped.
35
+ */
36
+ export async function findUseClientFiles(root: string, excludeDir?: string): Promise<string[]> {
37
+ const files: string[] = [];
38
+ // An unreadable directory only means its client deps are discovered
39
+ // late, through Vite's normal fallback — never fatal for dev startup.
40
+ await scanProjectSourceFiles(root, files, () => {}, 0, PARSEABLE_EXTENSIONS);
41
+ const excludePrefix = excludeDir ? excludeDir + sep : undefined;
42
+ const candidates = excludePrefix ? files.filter((f) => !f.startsWith(excludePrefix)) : files;
43
+
44
+ const matches = await Promise.all(
45
+ candidates.map(async (file) => {
46
+ const code = await readFile(file, 'utf8').catch(() => null);
47
+ // Cheap text gate before parsing: a directive is a literal
48
+ // without escapes, so a file lacking the text cannot carry it.
49
+ if (code === null || !code.includes('use client')) return null;
50
+ return detectDirective(code, file) === 'use client' ? file : null;
51
+ })
52
+ );
53
+ return matches.filter((file): file is string => file !== null);
54
+ }
55
+
56
+ /**
57
+ * Escape a path for use as an `optimizeDeps.entries` pattern. Vite globs
58
+ * entries, and route directories are full of glob syntax: `(group)`,
59
+ * `[slug]`, `[[...catchAll]]`, `@slot`. Slashes are normalized first, so
60
+ * the only backslashes left are literal (POSIX) ones, which are escaped
61
+ * too.
62
+ */
63
+ export function escapeGlobPath(path: string): string {
64
+ return normalizeSlashes(path).replace(/[\\()[\]{}*?|!+@]/g, '\\$&');
65
+ }
66
+
67
+ /**
68
+ * The client environment's `optimizeDeps.entries`: timber's browser
69
+ * entry plus every `'use client'` file in the project.
70
+ */
71
+ export async function clientOptimizeDepsEntries(
72
+ root: string,
73
+ browserEntryFile: string,
74
+ buildDir: string
75
+ ): Promise<string[]> {
76
+ const clientFiles = await findUseClientFiles(root, buildDir);
77
+ return [browserEntryFile, ...clientFiles].map(escapeGlobPath);
78
+ }
@@ -8,7 +8,7 @@
8
8
  * Design doc: 20-content-collections.md §"Content Collections"
9
9
  */
10
10
 
11
- import type { Plugin } from 'vite';
11
+ import type { Plugin, PluginOption } from 'vite';
12
12
  import { existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import type { PluginContext } from '../plugin-context.ts';
@@ -31,6 +31,27 @@ function findConfigFile(root: string): string | undefined {
31
31
  return undefined;
32
32
  }
33
33
 
34
+ /** The name @content-collections/vite gives its plugin. */
35
+ const CONTENT_COLLECTIONS_PLUGIN_NAME = 'content-collections';
36
+
37
+ /**
38
+ * Whether the user's own config already registers @content-collections/vite.
39
+ * Walks nested arrays and awaits promised plugins, the same PluginOption
40
+ * shapes Vite flattens (and has already resolved by the time config hooks
41
+ * run, so awaiting here costs nothing).
42
+ */
43
+ async function registersContentCollections(plugins: PluginOption[] | undefined): Promise<boolean> {
44
+ for (const option of plugins ?? []) {
45
+ const plugin = await option;
46
+ if (Array.isArray(plugin)) {
47
+ if (await registersContentCollections(plugin)) return true;
48
+ } else if (plugin && 'name' in plugin && plugin.name === CONTENT_COLLECTIONS_PLUGIN_NAME) {
49
+ return true;
50
+ }
51
+ }
52
+ return false;
53
+ }
54
+
34
55
  /**
35
56
  * Create the timber-content Vite plugin.
36
57
  *
@@ -75,6 +96,10 @@ export function timberContent(ctx: PluginContext): Plugin {
75
96
  name: 'timber-content',
76
97
 
77
98
  async config(config, env) {
99
+ // Two registrations start two builders and two watchers that race on
100
+ // the same cache ("Failed to parse the cache mapping"). The user's
101
+ // own registration, with its options, wins.
102
+ if (await registersContentCollections(config.plugins)) return;
78
103
  const root = config.root ?? ctx.root;
79
104
  ctx.timer.start('content-activate');
80
105
  await activate(root);
@@ -53,6 +53,13 @@ const ENTRY_FILE_MAP: Record<string, string> = {
53
53
  [VIRTUAL_IDS.browserEntry]: resolve(SRC_DIR, 'client', 'browser-entry', 'index.ts'),
54
54
  };
55
55
 
56
+ /**
57
+ * The real file behind virtual:timber-browser-entry. The client dep
58
+ * optimizer scans it explicitly once timber sets optimizeDeps.entries
59
+ * (see plugins/client-dep-entries.ts).
60
+ */
61
+ export const BROWSER_ENTRY_FILE = ENTRY_FILE_MAP[VIRTUAL_IDS.browserEntry];
62
+
56
63
  /** The \0-prefixed resolved ID for virtual:timber-config */
57
64
  const RESOLVED_CONFIG_ID = `\0${VIRTUAL_IDS.config}`;
58
65
 
@@ -103,8 +103,6 @@ export const SERVER_STUB_EXPORTS: readonly string[] = [
103
103
  // form-data
104
104
  'coerce',
105
105
  'parseFormData',
106
- // form-flash
107
- 'getFormFlash',
108
106
  // revalidation
109
107
  'revalidatePath',
110
108
  'revalidateTag',
@@ -16,6 +16,9 @@ export {
16
16
  loadServerAction,
17
17
  decodeReply,
18
18
  decodeAction,
19
+ // No-JS action result → the `useActionState` hook that submitted it
20
+ // (server/action-handler.ts), as Next.js does in its action handler.
21
+ decodeFormState,
19
22
  encryptActionBoundArgs,
20
23
  decryptActionBoundArgs,
21
24
  // Flight client running INSIDE the RSC environment — decodes a cached
@@ -25,3 +25,17 @@ declare module '@vitejs/plugin-rsc/vendor/react-server-dom/server.edge' {
25
25
  options?: { temporaryReferences?: unknown; arraySizeLimit?: number }
26
26
  ): Promise<unknown[]>;
27
27
  }
28
+
29
+ // src decodes through plugin-rsc's wrappers; tests decode a Flight response
30
+ // with the vendored client directly, as the browser's createFromFetch does.
31
+ declare module '@vitejs/plugin-rsc/vendor/react-server-dom/client.edge' {
32
+ export function createFromReadableStream(
33
+ stream: ReadableStream<Uint8Array>,
34
+ options: {
35
+ serverConsumerManifest: unknown;
36
+ nonce?: string;
37
+ temporaryReferences?: unknown;
38
+ encodeFormAction?: unknown;
39
+ }
40
+ ): PromiseLike<unknown>;
41
+ }