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

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 (170) hide show
  1. package/dist/_chunks/{actions-BerlqoXA.js → actions-Rjk4htmA.js} +19 -25
  2. package/dist/_chunks/{actions-BerlqoXA.js.map → actions-Rjk4htmA.js.map} +1 -1
  3. package/dist/_chunks/als-registry-DaxkVjt5.js.map +1 -1
  4. package/dist/_chunks/{cache-api-CR23J_NC.js → cache-api-DGdYfNJn.js} +4 -4
  5. package/dist/_chunks/{cache-api-CR23J_NC.js.map → cache-api-DGdYfNJn.js.map} +1 -1
  6. package/dist/_chunks/{chains-CpFg56UB.js → chains-BoO51joc.js} +2 -2
  7. package/dist/_chunks/{chains-CpFg56UB.js.map → chains-BoO51joc.js.map} +1 -1
  8. package/dist/_chunks/{cli-check-C6Ev6wBO.js → cli-check-ajNY3B2e.js} +3 -3
  9. package/dist/_chunks/{cli-check-C6Ev6wBO.js.map → cli-check-ajNY3B2e.js.map} +1 -1
  10. package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js → cli-schema-sync-D2eI8jEg.js} +2 -2
  11. package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js.map → cli-schema-sync-D2eI8jEg.js.map} +1 -1
  12. package/dist/_chunks/{file-cache-Dw6BJPG7.js → codegen-Bps1sLKJ.js} +3 -29
  13. package/dist/_chunks/codegen-Bps1sLKJ.js.map +1 -0
  14. package/dist/_chunks/{convention-lint-jKTwKwPe.js → convention-lint-DLmhGsRS.js} +54 -316
  15. package/dist/_chunks/convention-lint-DLmhGsRS.js.map +1 -0
  16. package/dist/_chunks/{dev-server-C4WZdB7L.js → dev-server-v97rQH4b.js} +99 -9
  17. package/dist/_chunks/dev-server-v97rQH4b.js.map +1 -0
  18. package/dist/_chunks/{error-boundary-tA7kVfs4.js → error-boundary-DsNScGRM.js} +4 -4
  19. package/dist/_chunks/{error-boundary-tA7kVfs4.js.map → error-boundary-DsNScGRM.js.map} +1 -1
  20. package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js → json-lossy-check-CVuRs2hG.js} +2 -2
  21. package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js.map → json-lossy-check-CVuRs2hG.js.map} +1 -1
  22. package/dist/_chunks/{live-graph-Dv-JJCZw.js → live-graph-9cSnn_h9.js} +3 -3
  23. package/dist/_chunks/{live-graph-Dv-JJCZw.js.map → live-graph-9cSnn_h9.js.map} +1 -1
  24. package/dist/_chunks/{logger-DiDt5ppH.js → logger-BP0LN6vP.js} +17 -2
  25. package/dist/_chunks/{logger-DiDt5ppH.js.map → logger-BP0LN6vP.js.map} +1 -1
  26. package/dist/_chunks/metadata-routes-DSDjM_hJ.js.map +1 -1
  27. package/dist/_chunks/navigation-root-BQfo1-kG.js.map +1 -1
  28. package/dist/_chunks/{poison-scan-CpeT6_OJ.js → poison-scan-vGV7Re0B.js} +2 -2
  29. package/dist/_chunks/{poison-scan-CpeT6_OJ.js.map → poison-scan-vGV7Re0B.js.map} +1 -1
  30. package/dist/_chunks/{scanner-Bw0oq1HB.js → scanner-DmqdxzbW.js} +392 -7
  31. package/dist/_chunks/scanner-DmqdxzbW.js.map +1 -0
  32. package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -1
  33. package/dist/_chunks/{sizeof-UwzwB1uM.js → sizeof-BM1409x2.js} +2 -2
  34. package/dist/_chunks/{sizeof-UwzwB1uM.js.map → sizeof-BM1409x2.js.map} +1 -1
  35. package/dist/_chunks/{status-page-marker-gaihi0KZ.js → status-page-marker-BRX9Ib-d.js} +1 -45
  36. package/dist/_chunks/status-page-marker-BRX9Ib-d.js.map +1 -0
  37. package/dist/_chunks/{walkers-BXExhzzk.js → walkers-Czu2jXFq.js} +3 -3
  38. package/dist/_chunks/{walkers-BXExhzzk.js.map → walkers-Czu2jXFq.js.map} +1 -1
  39. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  40. package/dist/analyze/crawl-entry.js +2 -2
  41. package/dist/analyze/graph-command.js +2 -2
  42. package/dist/cache/index.js +2 -2
  43. package/dist/cache/stores/memory.js +1 -1
  44. package/dist/cli.js +3 -3
  45. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  46. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  47. package/dist/client/error-boundary.js +1 -1
  48. package/dist/client/history.d.ts +0 -9
  49. package/dist/client/history.d.ts.map +1 -1
  50. package/dist/client/index.d.ts +2 -1
  51. package/dist/client/index.d.ts.map +1 -1
  52. package/dist/client/index.js +9 -3
  53. package/dist/client/index.js.map +1 -1
  54. package/dist/client/internal.js +25 -38
  55. package/dist/client/internal.js.map +1 -1
  56. package/dist/client/link.d.ts +22 -0
  57. package/dist/client/link.d.ts.map +1 -1
  58. package/dist/client/navigation-api.d.ts +14 -2
  59. package/dist/client/navigation-api.d.ts.map +1 -1
  60. package/dist/client/navigation-root.d.ts +9 -1
  61. package/dist/client/navigation-root.d.ts.map +1 -1
  62. package/dist/client/navigation-transition.d.ts +6 -1
  63. package/dist/client/navigation-transition.d.ts.map +1 -1
  64. package/dist/client/react-root.d.ts.map +1 -1
  65. package/dist/client/router-effects.d.ts +10 -3
  66. package/dist/client/router-effects.d.ts.map +1 -1
  67. package/dist/client/router-pipeline.d.ts +3 -1
  68. package/dist/client/router-pipeline.d.ts.map +1 -1
  69. package/dist/client/router-types.d.ts +65 -9
  70. package/dist/client/router-types.d.ts.map +1 -1
  71. package/dist/client/router.d.ts.map +1 -1
  72. package/dist/client/segment-cache.d.ts +0 -15
  73. package/dist/client/segment-cache.d.ts.map +1 -1
  74. package/dist/client/use-router.d.ts +13 -6
  75. package/dist/client/use-router.d.ts.map +1 -1
  76. package/dist/config-validation.d.ts +19 -2
  77. package/dist/config-validation.d.ts.map +1 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +7 -7
  80. package/dist/index.js.map +1 -1
  81. package/dist/routing/convention-lint.d.ts.map +1 -1
  82. package/dist/routing/export-detect.d.ts +19 -0
  83. package/dist/routing/export-detect.d.ts.map +1 -1
  84. package/dist/routing/index.js +3 -3
  85. package/dist/routing/manifest-codegen.d.ts.map +1 -1
  86. package/dist/routing/scanner.d.ts.map +1 -1
  87. package/dist/routing/types.d.ts +7 -0
  88. package/dist/routing/types.d.ts.map +1 -1
  89. package/dist/server/action-handler.d.ts +1 -1
  90. package/dist/server/action-handler.d.ts.map +1 -1
  91. package/dist/server/actions.d.ts +9 -4
  92. package/dist/server/actions.d.ts.map +1 -1
  93. package/dist/server/csrf.d.ts +38 -19
  94. package/dist/server/csrf.d.ts.map +1 -1
  95. package/dist/server/error-boundary-wrapper.d.ts +8 -4
  96. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  97. package/dist/server/fallback-error.d.ts.map +1 -1
  98. package/dist/server/form-flash.d.ts +1 -1
  99. package/dist/server/index.js +2 -2
  100. package/dist/server/index.js.map +1 -1
  101. package/dist/server/internal.js +249 -16
  102. package/dist/server/internal.js.map +1 -1
  103. package/dist/server/logger.d.ts +16 -3
  104. package/dist/server/logger.d.ts.map +1 -1
  105. package/dist/server/metadata-collector.d.ts +2 -0
  106. package/dist/server/metadata-collector.d.ts.map +1 -1
  107. package/dist/server/metadata-routes.d.ts +12 -1
  108. package/dist/server/metadata-routes.d.ts.map +1 -1
  109. package/dist/server/pipeline-helpers.d.ts +29 -1
  110. package/dist/server/pipeline-helpers.d.ts.map +1 -1
  111. package/dist/server/pipeline.d.ts +8 -1
  112. package/dist/server/pipeline.d.ts.map +1 -1
  113. package/dist/server/route-element-builder.d.ts.map +1 -1
  114. package/dist/server/route-matcher.d.ts +8 -0
  115. package/dist/server/route-matcher.d.ts.map +1 -1
  116. package/dist/server/rsc-entry/{wrap-action-dispatch.d.ts → action-dispatcher.d.ts} +18 -40
  117. package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -0
  118. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  119. package/dist/server/safe-load.d.ts +5 -12
  120. package/dist/server/safe-load.d.ts.map +1 -1
  121. package/docs/api/30-api-server.mdx +1 -1
  122. package/docs/api/31-api-client.mdx +23 -20
  123. package/docs/api/34-api-config.mdx +4 -2
  124. package/package.json +1 -1
  125. package/src/client/browser-entry/action-dispatch.ts +28 -28
  126. package/src/client/browser-entry/post-hydration.ts +11 -1
  127. package/src/client/browser-entry/router-init.ts +10 -4
  128. package/src/client/history.ts +0 -22
  129. package/src/client/index.ts +2 -1
  130. package/src/client/link.tsx +30 -1
  131. package/src/client/navigation-api.ts +36 -3
  132. package/src/client/navigation-root.tsx +13 -1
  133. package/src/client/navigation-transition.ts +17 -7
  134. package/src/client/react-root.ts +11 -2
  135. package/src/client/router-effects.ts +11 -4
  136. package/src/client/router-pipeline.ts +4 -0
  137. package/src/client/router-types.ts +80 -6
  138. package/src/client/router.ts +36 -24
  139. package/src/client/segment-cache.ts +0 -65
  140. package/src/client/use-router.ts +25 -6
  141. package/src/config-validation.ts +121 -5
  142. package/src/index.ts +5 -8
  143. package/src/routing/convention-lint.ts +75 -0
  144. package/src/routing/export-detect.ts +88 -0
  145. package/src/routing/manifest-codegen.ts +6 -0
  146. package/src/routing/scanner.ts +9 -0
  147. package/src/routing/types.ts +7 -0
  148. package/src/server/action-handler.ts +14 -11
  149. package/src/server/actions.ts +37 -42
  150. package/src/server/als-registry.ts +1 -1
  151. package/src/server/csrf.ts +100 -72
  152. package/src/server/error-boundary-wrapper.ts +11 -12
  153. package/src/server/fallback-error.ts +13 -21
  154. package/src/server/form-flash.ts +1 -1
  155. package/src/server/logger.ts +19 -3
  156. package/src/server/metadata-collector.ts +4 -1
  157. package/src/server/metadata-routes.ts +23 -8
  158. package/src/server/pipeline-helpers.ts +89 -8
  159. package/src/server/pipeline.ts +27 -2
  160. package/src/server/route-element-builder.ts +1 -0
  161. package/src/server/route-matcher.ts +11 -0
  162. package/src/server/rsc-entry/{wrap-action-dispatch.ts → action-dispatcher.ts} +20 -62
  163. package/src/server/rsc-entry/index.ts +14 -27
  164. package/src/server/safe-load.ts +5 -12
  165. package/dist/_chunks/convention-lint-jKTwKwPe.js.map +0 -1
  166. package/dist/_chunks/dev-server-C4WZdB7L.js.map +0 -1
  167. package/dist/_chunks/file-cache-Dw6BJPG7.js.map +0 -1
  168. package/dist/_chunks/scanner-Bw0oq1HB.js.map +0 -1
  169. package/dist/_chunks/status-page-marker-gaihi0KZ.js.map +0 -1
  170. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +0 -1
@@ -44,7 +44,7 @@ export function setupServerActions(): void {
44
44
  refreshWhenIdle(): void {
45
45
  const router = getRouterOrNull();
46
46
  if (!router) return;
47
- router.runWhenIdle(() => void router.refresh());
47
+ router.runWhenIdle(() => void router.refresh({ transitionType: 'revalidate' }));
48
48
  },
49
49
  // Every hold below is `applyActionResult` — the router's own refresh
50
50
  // or piggybacked commit. If it has not settled in `holdTimeoutMs`, the
@@ -56,7 +56,7 @@ export function setupServerActions(): void {
56
56
  recoverStalledHold(): void {
57
57
  const router = getRouterOrNull();
58
58
  if (!router) return;
59
- void router.refresh();
59
+ void router.refresh({ transitionType: 'revalidate' });
60
60
  },
61
61
  });
62
62
 
@@ -74,7 +74,7 @@ export function setupServerActions(): void {
74
74
 
75
75
  let hasRevalidation = false;
76
76
  let hasRedirect = false;
77
- const reval: { paths: string | null } = { paths: null };
77
+ let invalidated = false;
78
78
 
79
79
  const actionHeaders: Record<string, string> = {
80
80
  'Accept': RSC_CONTENT_TYPE,
@@ -103,30 +103,35 @@ export function setupServerActions(): void {
103
103
  }
104
104
  hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
105
105
  hasRedirect = res.headers.get('X-Timber-Redirect') != null;
106
- reval.paths = res.headers.get('X-Timber-Revalidation-Path');
106
+ invalidated = res.headers.get('X-Timber-Invalidated') === '1';
107
107
  return res;
108
108
  });
109
109
 
110
110
  const decoded = await createFromFetch(response);
111
111
 
112
- // TIM-1454: Invalidate non-matching paths unconditionally — the
113
- // mutation happened server-side regardless of navigation state.
114
- if (reval.paths) {
115
- if (router) {
116
- for (const p of reval.paths.split(' ')) {
117
- router.invalidatePath(p);
118
- }
119
- }
112
+ // The mutation happened server-side, and the next thing on screen is
113
+ // not a full render of the current page: revalidatePath named only
114
+ // other pages (TIM-1454), or the action redirected — from its body, or
115
+ // from a current-page revalidation whose render redirected (TIM-1461).
116
+ // Cached payloads go with the eviction below; this covers the mounted
117
+ // shared layouts the segment cache would otherwise let the server skip
118
+ // on the next navigation (TIM-1466). The tree and refresh branches
119
+ // re-render every layout, so they need neither.
120
+ if (router && (invalidated || hasRedirect)) {
121
+ router.suppressSegmentReuse();
120
122
  }
121
123
 
122
- // TIM-1476: When the server reports any revalidation, evict all
123
- // prefetch cache entries and history stack payloads — the mutation
124
- // may affect data any route reads, not just the paths the server
125
- // named. Placed before the redirect branch so an internal redirect
126
- // via router.navigate() cannot consume a stale prefetch (codex on
127
- // #1131). In-flight prefetch singleflights are fenced by the
128
- // eviction generation and will not repopulate the cache.
129
- if (router && (hasRevalidation || reval.paths)) {
124
+ // TIM-1476: Evict all prefetch cache entries and history stack
125
+ // payloads after every action — the mutation may affect data any route
126
+ // reads, not just the paths the server named, and an action that
127
+ // revalidated nothing still mutated (TIM-1518). Placed before every
128
+ // branch: before the redirect so an internal redirect via
129
+ // router.navigate() cannot consume a stale prefetch (codex on #1131),
130
+ // and synchronously before applyActionResult's hold so a navigation
131
+ // that supersedes the refresh cannot either. In-flight prefetch
132
+ // singleflights are fenced by the eviction generation and will not
133
+ // repopulate the cache.
134
+ if (router) {
130
135
  router.evictStaleCaches();
131
136
  }
132
137
 
@@ -178,7 +183,7 @@ export function setupServerActions(): void {
178
183
  .then(
179
184
  (applied) => {
180
185
  if (!applied) {
181
- // Synchronous eviction already ran at line 121 — only
186
+ // Synchronous eviction already ran above — only
182
187
  // the refresh flag needs deferral (TIM-1518).
183
188
  queue.markNeedsRefresh();
184
189
  }
@@ -192,7 +197,7 @@ export function setupServerActions(): void {
192
197
  return wrapper._action;
193
198
  }
194
199
 
195
- // revalidatePath was called but only for non-current paths —
200
+ // revalidatePath was called but only for other pages —
196
201
  // caches were already invalidated above, so no refresh is needed
197
202
  // unless a navigation ran while this action was in flight. That
198
203
  // navigation may have consumed a pre-mutation prefetch before the
@@ -200,7 +205,7 @@ export function setupServerActions(): void {
200
205
  // now on screen. The other branches catch this through
201
206
  // applyActionResult's epoch check; this one must check itself
202
207
  // (TIM-1483).
203
- if (reval.paths) {
208
+ if (invalidated) {
204
209
  if (router && epoch && !router.isEpochCurrent(epoch)) {
205
210
  queue.markNeedsRefresh();
206
211
  }
@@ -212,11 +217,6 @@ export function setupServerActions(): void {
212
217
  // the queue until the refresh commits so the next action captures a
213
218
  // current epoch (codex on #1125).
214
219
  if (router && epoch) {
215
- // TIM-1518: Evict synchronously so a navigation that supersedes
216
- // this action's refresh cannot consume a pre-mutation prefetch.
217
- // The deferred eviction in .then() ran as a microtask — after the
218
- // superseding navigation had already consumed a stale entry.
219
- router.evictStaleCaches();
220
220
  hold(
221
221
  router.applyActionResult(epoch).then(
222
222
  (applied) => {
@@ -61,7 +61,17 @@ export function setupPostHydration({ router, navApiController }: PostHydrationOp
61
61
 
62
62
  const state = window.history.state;
63
63
  const scrollY = state && typeof state.scrollY === 'number' ? state.scrollY : 0;
64
- void router.handlePopState(window.location.pathname + locationSearch(), scrollY);
64
+ // `popstate` does not say which way the user went, and timber writes no
65
+ // index into `history.state` to work it out: any `pushState` the app
66
+ // makes itself (nuqs, a shallow update) can overwrite it. So the render is
67
+ // tagged `navigation-traverse`. The Navigation API path (navigation-api.ts)
68
+ // is the one that knows back from forward (TIM-1471).
69
+ void router.handlePopState(
70
+ window.location.pathname + locationSearch(),
71
+ scrollY,
72
+ undefined,
73
+ 'navigation-traverse'
74
+ );
65
75
  });
66
76
 
67
77
  // Keep scroll position up to date as the user scrolls.
@@ -215,7 +215,12 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
215
215
  queueMicrotask(() => router?.settleHandoffs());
216
216
 
217
217
  publishTree(tree, params, newNavState);
218
- render(renderTree(tree, newNavState, params), null);
218
+ // No transition types: this is not a navigation, and a
219
+ // `<ViewTransition>` keyed on the router's types must not mistake a
220
+ // query-string sync for one. It is still a transition commit, though, so
221
+ // it claims whatever types a still-suspended navigation queued
222
+ // (design/37-navigation-api.md §"The claim window").
223
+ render(renderTree(tree, newNavState, params), null, []);
219
224
  }
220
225
 
221
226
  window.history.pushState = function (
@@ -294,7 +299,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
294
299
  //
295
300
  // `_url` names the pending URL the router already published to its own
296
301
  // pending store before calling here; the transition has no use for it.
297
- navigateTransition: (_url: string, owner: RenderOwner, perform, onCommit) => {
302
+ navigateTransition: (_url: string, owner: RenderOwner, types, perform, onCommit) => {
298
303
  return navigateTransition(
299
304
  owner,
300
305
  async () => {
@@ -330,6 +335,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
330
335
  };
331
336
  },
332
337
  render,
338
+ types,
333
339
  onCommit
334
340
  );
335
341
  },
@@ -364,9 +370,9 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
364
370
  _departingUrl: departingUrl,
365
371
  });
366
372
  },
367
- onTraverse: async (url, scrollY, signal) => {
373
+ onTraverse: async (url, scrollY, signal, direction) => {
368
374
  // Back/forward — delegate to the router's popstate handler.
369
- await router.handlePopState(url, scrollY, signal);
375
+ await router.handlePopState(url, scrollY, signal, direction);
370
376
  },
371
377
  onShallowNavigate: (destinationUrl: string) => {
372
378
  const search = new URL(destinationUrl).search;
@@ -93,28 +93,6 @@ export class HistoryStack {
93
93
  return this.entries.has(routerUrlKey(url));
94
94
  }
95
95
 
96
- delete(url: string): boolean {
97
- return this.entries.delete(routerUrlKey(url));
98
- }
99
-
100
- /**
101
- * Delete all entries whose pathname matches `pathname` (TIM-1465).
102
- * Used by `invalidatePath` when the invalidation target has no search
103
- * string — `/products` should evict `/products?page=1` too, because
104
- * `revalidatePath('/products')` invalidates the route regardless of
105
- * query.
106
- */
107
- deleteByPathname(pathname: string): void {
108
- pathname = routerUrlKey(pathname);
109
- for (const url of this.entries.keys()) {
110
- const qIndex = url.indexOf('?');
111
- const urlPathname = qIndex === -1 ? url : url.slice(0, qIndex);
112
- if (urlPathname === pathname) {
113
- this.entries.delete(url);
114
- }
115
- }
116
- }
117
-
118
96
  /**
119
97
  * Evict every entry (TIM-1476). Called after a server action that
120
98
  * revalidated data — history entries are equally stale since they replay
@@ -64,7 +64,8 @@ export { usePendingNavigation } from './use-pending-navigation.ts';
64
64
  export { useLinkStatus, LinkStatusContext } from './use-link-status.ts';
65
65
  export type { LinkStatus } from './use-link-status.ts';
66
66
  export { useRouter } from './use-router.ts';
67
- export type { AppRouterInstance } from './use-router.ts';
67
+ export type { AppRouterInstance, NavigateOptions } from './use-router.ts';
68
+ export type { NavigationTransitionType } from './router-types.ts';
68
69
  export { usePathname } from './use-pathname.ts';
69
70
  export { replaceUrl } from './shallow-url.ts';
70
71
  // useSearchParams removed from public exports — lives in next/navigation shim only.
@@ -94,6 +94,11 @@ export interface LinkBaseProps extends Omit<AnchorHTMLAttributes<HTMLAnchorEleme
94
94
  * Set to false for tabbed interfaces where content changes within a fixed layout.
95
95
  */
96
96
  scroll?: boolean;
97
+ /**
98
+ * Replace the current history entry instead of pushing a new one, so Back
99
+ * skips the page the link was on.
100
+ */
101
+ replace?: boolean;
97
102
  /**
98
103
  * Preserve search params from the current URL across navigation.
99
104
  *
@@ -117,6 +122,23 @@ export interface LinkBaseProps extends Omit<AnchorHTMLAttributes<HTMLAnchorEleme
117
122
  * Has no effect during SSR.
118
123
  */
119
124
  onNavigate?: OnNavigateHandler;
125
+ /**
126
+ * View transition types to add to this link's navigation, beside the
127
+ * router's own `navigation-forward`. A `<ViewTransition>` in the
128
+ * destination (or the page being left) can key its animation on them:
129
+ *
130
+ * ```tsx
131
+ * <Link href="/products/1" transitionTypes={['to-detail']}>…</Link>
132
+ * ```
133
+ *
134
+ * The router adds them in the transition that renders the destination. A
135
+ * `startTransition(() => { addTransitionType(t); router.push(href) })` in
136
+ * an `onClick` does not work in timber: the destination reaches React only
137
+ * after its fetch, in a later transition, and React hands types added in
138
+ * the click to whatever transition commits next — not to that one
139
+ * (design/37-navigation-api.md §"Transition types").
140
+ */
141
+ transitionTypes?: readonly string[];
120
142
  children?: ReactNode;
121
143
  }
122
144
 
@@ -475,10 +497,12 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
475
497
  href,
476
498
  prefetch,
477
499
  scroll,
500
+ replace,
478
501
  segmentParams,
479
502
  searchParams,
480
503
  preserveSearchParams,
481
504
  onNavigate,
505
+ transitionTypes,
482
506
  onClick: userOnClick,
483
507
  onMouseEnter: userOnMouseEnter,
484
508
  children,
@@ -620,7 +644,12 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
620
644
  if (committed) clear();
621
645
  };
622
646
  setIsPending(true);
623
- const navigation = router.navigate(absoluteHref, { scroll: shouldScroll, onCommit });
647
+ const navigation = router.navigate(absoluteHref, {
648
+ scroll: shouldScroll,
649
+ replace,
650
+ transitionTypes,
651
+ onCommit,
652
+ });
624
653
  navigation.then(settle, (error: unknown) => {
625
654
  clear();
626
655
  // Rethrow, so a navigation error that the router did not already
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { isHardNavigating } from './navigation-root.tsx';
21
21
  import { stripRscCacheKey } from '../shared/rsc-cache-key.ts';
22
+ import type { TraverseDirection } from './router-types.ts';
22
23
 
23
24
  // ─── Feature Detection ───────────────────────────────────────────
24
25
 
@@ -38,6 +39,29 @@ export function getNavigationApi(): Navigation | null {
38
39
  return window.navigation;
39
40
  }
40
41
 
42
+ // ─── Traverse Direction ──────────────────────────────────────────
43
+
44
+ /**
45
+ * Which way a traversal goes, as the view transition type the router adds
46
+ * for it (design/37-navigation-api.md §"Transition types").
47
+ *
48
+ * The Navigation API numbers the session's entries, so a traversal to a
49
+ * lower index is back and to a higher one is forward — including a jump of
50
+ * several entries (`history.go(-3)`). An index of -1 means the entry is not
51
+ * in this document's list, and then the direction is unknown.
52
+ */
53
+ export function traverseDirection(
54
+ destinationIndex: number,
55
+ currentIndex: number | undefined
56
+ ): TraverseDirection {
57
+ if (currentIndex === undefined || currentIndex < 0 || destinationIndex < 0) {
58
+ return 'navigation-traverse';
59
+ }
60
+ if (destinationIndex < currentIndex) return 'navigation-back';
61
+ if (destinationIndex > currentIndex) return 'navigation-forward';
62
+ return 'navigation-traverse';
63
+ }
64
+
41
65
  // ─── Navigation API Controller ───────────────────────────────────
42
66
 
43
67
  /**
@@ -60,9 +84,15 @@ export interface NavigationApiCallbacks {
60
84
 
61
85
  /**
62
86
  * Handle a traversal (back/forward button). The Navigation API intercepts
63
- * the traversal and delegates to us for RSC replay/fetch.
87
+ * the traversal and delegates to us for RSC replay/fetch. `direction` is
88
+ * the view transition type the render adds — see `traverseDirection`.
64
89
  */
65
- onTraverse: (url: string, scrollY: number, signal: AbortSignal) => Promise<void>;
90
+ onTraverse: (
91
+ url: string,
92
+ scrollY: number,
93
+ signal: AbortSignal,
94
+ direction: TraverseDirection
95
+ ) => Promise<void>;
66
96
 
67
97
  /**
68
98
  * Called when a shallow URL update is intercepted (e.g., nuqs with
@@ -231,6 +261,9 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
231
261
  | null
232
262
  | undefined;
233
263
  const scrollY = entryState && typeof entryState.scrollY === 'number' ? entryState.scrollY : 0;
264
+ // Read before intercept(): the current entry is still the one the
265
+ // user is leaving.
266
+ const direction = traverseDirection(event.destination.index, nav.currentEntry?.index);
234
267
 
235
268
  event.intercept({
236
269
  // Manual scroll — we handle scroll restoration ourselves
@@ -238,7 +271,7 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
238
271
  scroll: 'manual',
239
272
  focusReset: 'manual',
240
273
  async handler() {
241
- await callbacks.onTraverse(url, scrollY, event.signal);
274
+ await callbacks.onTraverse(url, scrollY, event.signal, direction);
242
275
  },
243
276
  });
244
277
  } else if (event.navigationType === 'push' || event.navigationType === 'replace') {
@@ -71,8 +71,20 @@ export interface RenderedTree {
71
71
  * navigation, a revalidation, a popstate replay, a shallow search re-wrap —
72
72
  * and it is always a synchronous `startTransition` around `root.render`.
73
73
  * The production one is `createReactRoot().render`.
74
+ *
75
+ * `types` are the view transition types this render animates as
76
+ * (design/37-navigation-api.md §"Transition types"). They are added with
77
+ * `addTransitionType` inside that same `startTransition`, and nowhere else:
78
+ * React queues types per root and hands them to the next transition-only
79
+ * commit, so a type added in any other scope describes whichever tree commits
80
+ * next rather than this one (TIM-1463, TIM-1471). Empty for the shallow search
81
+ * re-wrap, which re-renders the displayed page rather than changing it.
74
82
  */
75
- export type NavigationRender = (element: ReactNode, publish: (() => void) | null) => void;
83
+ export type NavigationRender = (
84
+ element: ReactNode,
85
+ publish: (() => void) | null,
86
+ types: readonly string[]
87
+ ) => void;
76
88
 
77
89
  // ─── Hard Navigation Guard ──────────────────────────────────────
78
90
 
@@ -224,6 +224,11 @@ export function createRenderOwner(kind: 'navigation' | 'revalidation'): RenderOw
224
224
  * how `<Link>` keeps `isPending` up until the destination is on screen
225
225
  * without scheduling anything against React's lanes (TIM-1418).
226
226
  *
227
+ * `types` are the view transition types `render` adds beside the tree
228
+ * (design/37-navigation-api.md §"Transition types"). They are fixed before
229
+ * `perform` runs — what a render animates as is decided by the operation that
230
+ * started it, not by what the fetch returns.
231
+ *
227
232
  * `owner` is the `RenderOwner` for this render (TIM-1481). Supersession is
228
233
  * detected via `owner.outcome` (sync) and `owner.displaced` (async).
229
234
  * No module-level state; no globalThis singleton.
@@ -234,6 +239,7 @@ export function navigateTransition(
234
239
  owner: RenderOwner,
235
240
  perform: () => Promise<TransitionResult>,
236
241
  render: NavigationRender,
242
+ types: readonly string[],
237
243
  onCommit?: (outcome: CommitOutcome) => void
238
244
  ): Promise<void> {
239
245
  const superseded = () => new DOMException('Navigation superseded', 'AbortError');
@@ -267,13 +273,17 @@ export function navigateTransition(
267
273
  //
268
274
  // This is THE update that must not hide the departing page (TIM-1306);
269
275
  // `render` is the synchronous transition around `root.render`.
270
- render(element, () => {
271
- try {
272
- commit();
273
- } finally {
274
- settleCommit('committed');
275
- }
276
- });
276
+ render(
277
+ element,
278
+ () => {
279
+ try {
280
+ commit();
281
+ } finally {
282
+ settleCommit('committed');
283
+ }
284
+ },
285
+ types
286
+ );
277
287
  // React may commit the tree before this settles — that is the point:
278
288
  // the destination reveals as React is able to render it
279
289
  // rather than waiting for the whole Flight stream. The await is here so
@@ -19,7 +19,7 @@
19
19
  * `container` is `document` in production; tests pass an element.
20
20
  */
21
21
 
22
- import { createElement, startTransition, type ReactNode } from 'react';
22
+ import { addTransitionType, createElement, startTransition, type ReactNode } from 'react';
23
23
  import { createRoot, hydrateRoot, type HydrationOptions, type Root } from 'react-dom/client';
24
24
  import { NavigationRoot, type NavigationRender, type RenderedTree } from './navigation-root.tsx';
25
25
  import type { TopLoaderConfig } from './top-loader.tsx';
@@ -55,13 +55,22 @@ export function createReactRoot(options: {
55
55
  hydrate(element, hydrationOptions) {
56
56
  root = hydrateRoot(container, rootElement({ element, publish: null }), hydrationOptions);
57
57
  },
58
- render(element, publish) {
58
+ render(element, publish, types) {
59
59
  const target = (root ??= createRoot(container));
60
60
  // Synchronous, and returns nothing: an async callback would drop the
61
61
  // transition scope at its first await, and a returned thenable would
62
62
  // open an action scope that holds the commit until it settles
63
63
  // (TIM-1306, TIM-1307 — see client/navigation-transition.ts).
64
+ //
65
+ // The view transition types go in this scope and no other: React
66
+ // queues them on the root when the scope closes, and only if the root
67
+ // then has a transition pending — which `target.render` just made true.
68
+ // They are claimed by the next transition-only commit, so from here
69
+ // until this tree commits, another transition commit on the root can
70
+ // take them, and a superseding render merges its types with these
71
+ // (design/37-navigation-api.md §"The claim window", TIM-1471).
64
72
  startTransition(() => {
73
+ for (const type of types) addTransitionType(type);
65
74
  target.render(rootElement({ element, publish }));
66
75
  });
67
76
  },
@@ -146,8 +146,13 @@ export interface NavigationRecoveryDeps {
146
146
  /** The owner that holds the router right now, or null when idle. */
147
147
  currentOwner: () => RenderOwner | null;
148
148
  leaveSpaIfOwned: SpaExits['leaveSpaIfOwned'];
149
- /** Replace the current entry with `url` — the router's own `navigate()`. */
150
- navigate: (url: string) => Promise<void>;
149
+ /**
150
+ * Replace the current entry with `url` — the router's own `navigate()`,
151
+ * rendering with `types`: the view transition types of the render the
152
+ * redirect interrupted, so a redirected Back still animates as Back and a
153
+ * link's own `transitionTypes` survive the hop (TIM-1471).
154
+ */
155
+ navigate: (url: string, types: readonly string[]) => Promise<void>;
151
156
  }
152
157
 
153
158
  /**
@@ -181,13 +186,15 @@ export function createNavigationRecovery({
181
186
  error: unknown,
182
187
  owner: RenderOwner,
183
188
  url: string,
184
- fromUrl: string
189
+ fromUrl: string,
190
+ /** The view transition types of the render that failed. */
191
+ types: readonly string[]
185
192
  ): Promise<boolean> {
186
193
  if (error instanceof RedirectError) {
187
194
  // Same ownership rule as leaving the SPA: a superseded navigation must
188
195
  // not steer the document to the destination it was abandoned for.
189
196
  if (currentOwner() !== owner) return true;
190
- await navigate(error.redirectUrl);
197
+ await navigate(error.redirectUrl, types);
191
198
  return true;
192
199
  }
193
200
  // A server error, a non-RSC response and a version skew all end the same
@@ -81,6 +81,8 @@ export interface NavigationPipeline {
81
81
  renderViaTransition: (
82
82
  url: string,
83
83
  owner: RenderOwner,
84
+ /** The view transition types the render adds (`NavigationRender`). */
85
+ types: readonly string[],
84
86
  perform: () => Promise<NavigationPayload>,
85
87
  /** See `NavigationOptions.onCommit`. */
86
88
  onCommit?: (outcome: CommitOutcome) => void
@@ -183,6 +185,7 @@ export function createNavigationPipeline({
183
185
  async function renderViaTransition(
184
186
  url: string,
185
187
  owner: RenderOwner,
188
+ types: readonly string[],
186
189
  perform: () => Promise<NavigationPayload>,
187
190
  onCommit?: (outcome: CommitOutcome) => void
188
191
  ): Promise<void> {
@@ -203,6 +206,7 @@ export function createNavigationPipeline({
203
206
  await deps.navigateTransition(
204
207
  url,
205
208
  owner,
209
+ types,
206
210
  async (wrapPayload) => {
207
211
  const result = await perform();
208
212
  // Await the payload's *root row* — the same thing React would suspend
@@ -16,11 +16,70 @@ import type { SegmentCache, PrefetchCache } from './segment-cache.ts';
16
16
  import type { TransitionResult, CommitOutcome, RenderOwner } from './navigation-transition.ts';
17
17
  import type { NavigationEpoch } from './router-lifecycle.ts';
18
18
 
19
+ /**
20
+ * The view transition type the router adds to a render it hands React —
21
+ * exactly one per navigation, traversal, refresh or revalidation render,
22
+ * beside any `transitionTypes` the caller passed.
23
+ * A `<ViewTransition>` keys its animations on these
24
+ * (design/37-navigation-api.md §"Transition types", TIM-1471).
25
+ *
26
+ * - `navigation-forward` — a push or replace (`<Link>`, `router.push`,
27
+ * a server action redirect, a non-shallow nuqs update), or a traversal
28
+ * forward through history.
29
+ * - `navigation-back` — a traversal back through history.
30
+ * - `navigation-traverse` — a traversal whose direction the router cannot
31
+ * know: the History API fallback, where `popstate` carries no index.
32
+ * - `refresh` — `router.refresh()`.
33
+ * - `revalidate` — the page re-rendered after a server action.
34
+ *
35
+ * The shallow search re-wrap and hydration add none. A server redirect the
36
+ * router follows keeps the types of the render it interrupted.
37
+ */
38
+ export type NavigationTransitionType =
39
+ | 'navigation-forward'
40
+ | 'navigation-back'
41
+ | 'navigation-traverse'
42
+ | 'refresh'
43
+ | 'revalidate';
44
+
45
+ /** The type a traversal adds: its direction, when the router knows it. */
46
+ export type TraverseDirection = Extract<
47
+ NavigationTransitionType,
48
+ 'navigation-forward' | 'navigation-back' | 'navigation-traverse'
49
+ >;
50
+
51
+ /** Options for `RouterInstance.refresh`. */
52
+ export interface RefreshOptions {
53
+ /** See `NavigationOptions.onCommit`. */
54
+ onCommit?: (outcome: CommitOutcome) => void;
55
+ /**
56
+ * Why the page is being re-fetched, as the view transition type the render
57
+ * adds: `refresh` (the default) when the user asked, `revalidate` when a
58
+ * server action left the page to be re-fetched.
59
+ */
60
+ transitionType?: Extract<NavigationTransitionType, 'refresh' | 'revalidate'>;
61
+ }
62
+
19
63
  export interface NavigationOptions {
20
64
  /** Set to false to prevent scroll-to-top on forward navigation */
21
65
  scroll?: boolean;
22
66
  /** Use replaceState instead of pushState (replaces current history entry) */
23
67
  replace?: boolean;
68
+ /**
69
+ * View transition types to add to this navigation, beside the router's own
70
+ * `navigation-forward`. They are added in the router's transition, the one
71
+ * that renders the destination — not in the caller's: React claims types
72
+ * per root at the next transition commit, and the destination is handed to
73
+ * React only after its fetch, so a type added around the `navigate()` call
74
+ * never reaches it (design/37-navigation-api.md §"Transition types").
75
+ */
76
+ transitionTypes?: readonly string[];
77
+ /**
78
+ * @internal The complete view transition types for this render, replacing
79
+ * `navigation-forward` + `transitionTypes`. Set only when the router
80
+ * follows a redirect, which keeps the types of the render it interrupted.
81
+ */
82
+ _renderTypes?: readonly string[];
24
83
  /**
25
84
  * Runs exactly once, when React commits this navigation's tree — or when
26
85
  * the navigation is abandoned (superseded or failed) and no such commit
@@ -97,6 +156,8 @@ export interface RouterDeps {
97
156
  navigateTransition: (
98
157
  pendingUrl: string,
99
158
  owner: RenderOwner,
159
+ /** The view transition types this render adds; see `NavigationRender`. */
160
+ types: readonly string[],
100
161
  perform: (
101
162
  wrapPayload: (
102
163
  payload: unknown,
@@ -184,9 +245,19 @@ export interface RouterInstance {
184
245
  /** Navigate to a new URL (forward navigation) */
185
246
  navigate(url: string, options?: NavigationOptions): Promise<void>;
186
247
  /** Full re-render of the current URL — no state tree sent */
187
- refresh(options?: { onCommit?: (outcome: CommitOutcome) => void }): Promise<void>;
188
- /** Handle a popstate event (back/forward button). scrollY is read from history.state. */
189
- handlePopState(url: string, scrollY?: number, externalSignal?: AbortSignal): Promise<void>;
248
+ refresh(options?: RefreshOptions): Promise<void>;
249
+ /**
250
+ * Handle a traversal (back/forward button). scrollY is read from the
251
+ * destination entry's state. `direction` is the transition type the
252
+ * render adds; it defaults to `navigation-traverse`, the type for a
253
+ * traversal whose direction is unknown.
254
+ */
255
+ handlePopState(
256
+ url: string,
257
+ scrollY?: number,
258
+ externalSignal?: AbortSignal,
259
+ direction?: TraverseDirection
260
+ ): Promise<void>;
190
261
  /** Whether a navigation is currently in flight */
191
262
  isPending(): boolean;
192
263
  /** The URL currently being navigated to, or null if idle */
@@ -237,10 +308,13 @@ export interface RouterInstance {
237
308
  */
238
309
  settleHandoffs(): void;
239
310
  /**
240
- * Invalidate client caches for a path so the next navigation fetches
241
- * fresh data. Called when `revalidatePath` targeted a non-current path.
311
+ * Make the next navigation a full render: the server re-renders every
312
+ * segment instead of skipping the shared layouts the client reports as
313
+ * mounted (TIM-1466). Called when `revalidatePath` named a page other
314
+ * than the current one — a layout that page shares with this one may now
315
+ * be stale, and the server cannot tell that from the state tree.
242
316
  */
243
- invalidatePath(path: string): void;
317
+ suppressSegmentReuse(): void;
244
318
  /**
245
319
  * Evict all prefetch cache entries and history stack payloads (TIM-1476).
246
320
  * Called after a server action that revalidated data — the mutation may