@timber-js/app 0.2.0-alpha.211 → 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 (140) 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-DGX9zmg9.js → chains-BfoPFraI.js} +2 -2
  9. package/dist/_chunks/{chains-DGX9zmg9.js.map → chains-BfoPFraI.js.map} +1 -1
  10. package/dist/_chunks/{classify-QwG5rxKI.js → classify-BT66U83D.js} +2 -2
  11. package/dist/_chunks/{classify-QwG5rxKI.js.map → classify-BT66U83D.js.map} +1 -1
  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-2HCF09no.js → client-dep-entries-CQwpb8dI.js} +2 -2
  17. package/dist/_chunks/{client-dep-entries-2HCF09no.js.map → client-dep-entries-CQwpb8dI.js.map} +1 -1
  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-TFpEwm3H.js → dev-server-FKxptbnI.js} +2 -2
  21. package/dist/_chunks/{dev-server-TFpEwm3H.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-cNuWMYQI.js → live-graph-C_4v-fHv.js} +4 -4
  25. package/dist/_chunks/{live-graph-cNuWMYQI.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-CfQ3unZR.js → poison-scan-C92liMAr.js} +2 -2
  29. package/dist/_chunks/{poison-scan-CfQ3unZR.js.map → poison-scan-C92liMAr.js.map} +1 -1
  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/crawl-entry.js +3 -3
  38. package/dist/analyze/graph-command.js +2 -2
  39. package/dist/cache/index.js +2 -2
  40. package/dist/cache/stores/memory.js +1 -1
  41. package/dist/cdn/workers-cache-purge.js +1 -1
  42. package/dist/cli.js +3 -3
  43. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  44. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  45. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  46. package/dist/client/form.d.ts +17 -64
  47. package/dist/client/form.d.ts.map +1 -1
  48. package/dist/client/index.d.ts +2 -2
  49. package/dist/client/index.d.ts.map +1 -1
  50. package/dist/client/index.js +23 -51
  51. package/dist/client/index.js.map +1 -1
  52. package/dist/client/internal.js +25 -25
  53. package/dist/client/internal.js.map +1 -1
  54. package/dist/client/navigation-api.d.ts +25 -63
  55. package/dist/client/navigation-api.d.ts.map +1 -1
  56. package/dist/client/router-lifecycle.d.ts +17 -11
  57. package/dist/client/router-lifecycle.d.ts.map +1 -1
  58. package/dist/client/router-pipeline.d.ts +0 -1
  59. package/dist/client/router-pipeline.d.ts.map +1 -1
  60. package/dist/client/router-types.d.ts +14 -45
  61. package/dist/client/router-types.d.ts.map +1 -1
  62. package/dist/client/router.d.ts.map +1 -1
  63. package/dist/index.js +6 -7
  64. package/dist/index.js.map +1 -1
  65. package/dist/plugins/shims.d.ts.map +1 -1
  66. package/dist/routing/index.js +2 -2
  67. package/dist/rsc-runtime/rsc.d.ts +1 -1
  68. package/dist/rsc-runtime/rsc.d.ts.map +1 -1
  69. package/dist/server/action-client.d.ts +9 -5
  70. package/dist/server/action-client.d.ts.map +1 -1
  71. package/dist/server/action-handler.d.ts +27 -8
  72. package/dist/server/action-handler.d.ts.map +1 -1
  73. package/dist/server/als-registry.d.ts +22 -2
  74. package/dist/server/als-registry.d.ts.map +1 -1
  75. package/dist/server/client-error-message.d.ts +12 -0
  76. package/dist/server/client-error-message.d.ts.map +1 -0
  77. package/dist/server/form-state-embed.d.ts +32 -0
  78. package/dist/server/form-state-embed.d.ts.map +1 -0
  79. package/dist/server/index.d.ts +0 -2
  80. package/dist/server/index.d.ts.map +1 -1
  81. package/dist/server/index.js +7 -40
  82. package/dist/server/index.js.map +1 -1
  83. package/dist/server/internal.js +10 -6
  84. package/dist/server/internal.js.map +1 -1
  85. package/dist/server/logger.d.ts +1 -0
  86. package/dist/server/logger.d.ts.map +1 -1
  87. package/dist/server/pipeline.d.ts +20 -6
  88. package/dist/server/pipeline.d.ts.map +1 -1
  89. package/dist/server/request-context.d.ts +27 -2
  90. package/dist/server/request-context.d.ts.map +1 -1
  91. package/dist/server/route-element-builder.d.ts.map +1 -1
  92. package/dist/server/rsc-entry/action-dispatcher.d.ts +6 -5
  93. package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
  94. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  95. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  96. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  97. package/dist/server/ssr-bridge-types.d.ts +8 -0
  98. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  99. package/dist/server/ssr-entry.d.ts.map +1 -1
  100. package/dist/server/ssr-render.d.ts +3 -0
  101. package/dist/server/ssr-render.d.ts.map +1 -1
  102. package/docs/api/31-api-client.mdx +9 -3
  103. package/docs/learn/08-forms-and-actions.mdx +21 -28
  104. package/package.json +1 -1
  105. package/src/client/browser-entry/action-dispatch.ts +74 -18
  106. package/src/client/browser-entry/hydrate.ts +18 -0
  107. package/src/client/browser-entry/router-init.ts +4 -18
  108. package/src/client/form.tsx +33 -98
  109. package/src/client/index.ts +2 -2
  110. package/src/client/navigation-api.ts +47 -173
  111. package/src/client/navigation-transition.ts +2 -2
  112. package/src/client/router-lifecycle.ts +40 -20
  113. package/src/client/router-pipeline.ts +3 -9
  114. package/src/client/router-types.ts +14 -49
  115. package/src/client/router.ts +38 -58
  116. package/src/plugins/shims.ts +0 -2
  117. package/src/rsc-runtime/rsc.ts +3 -0
  118. package/src/rsc-runtime/vendor-types.d.ts +14 -0
  119. package/src/server/action-client.ts +18 -20
  120. package/src/server/action-handler.ts +133 -95
  121. package/src/server/als-registry.ts +23 -9
  122. package/src/server/client-error-message.ts +18 -0
  123. package/src/server/form-state-embed.ts +63 -0
  124. package/src/server/index.ts +0 -4
  125. package/src/server/logger.ts +6 -1
  126. package/src/server/pipeline.ts +27 -8
  127. package/src/server/request-context.ts +39 -2
  128. package/src/server/route-element-builder.ts +12 -1
  129. package/src/server/rsc-entry/action-dispatcher.ts +37 -34
  130. package/src/server/rsc-entry/error-renderer.ts +2 -1
  131. package/src/server/rsc-entry/rsc-stream.ts +20 -11
  132. package/src/server/rsc-entry/ssr-renderer.ts +14 -1
  133. package/src/server/ssr-bridge-types.ts +9 -0
  134. package/src/server/ssr-entry.ts +1 -0
  135. package/src/server/ssr-render.ts +5 -0
  136. package/dist/_chunks/actions-Rjk4htmA.js.map +0 -1
  137. package/dist/_chunks/als-registry-DaxkVjt5.js.map +0 -1
  138. package/dist/server/form-flash.d.ts +0 -78
  139. package/dist/server/form-flash.d.ts.map +0 -1
  140. package/src/server/form-flash.ts +0 -89
@@ -159,9 +159,8 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
159
159
  // itself calls pushState/replaceState, it sets a flag so the patch
160
160
  // skips the sync — the router already updates NavigationContext.
161
161
  //
162
- // On Navigation API browsers, the navigate event catches most URL
163
- // changes. The patch is defense-in-depth and the primary mechanism
164
- // for History API-only browsers (older Safari/Firefox).
162
+ // This is the mechanism with or without the Navigation API: its navigate
163
+ // listener leaves same-document pushState/replaceState alone.
165
164
  const ROUTER_HISTORY_FLAG = Symbol.for('__timber_router_history_update');
166
165
  const gFlags = globalThis as Record<symbol, boolean>;
167
166
 
@@ -258,7 +257,6 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
258
257
  window.history.replaceState(data, unused, url);
259
258
  gFlags[ROUTER_HISTORY_FLAG] = false;
260
259
  },
261
- navigationApiActive: useNavApi,
262
260
  scrollTo: (x, y) => {
263
261
  // Scroll the document viewport.
264
262
  window.scrollTo(x, y);
@@ -361,15 +359,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
361
359
  let navApiController: NavigationApiController | null = null;
362
360
  if (useNavApi) {
363
361
  navApiController = setupNavigationApi({
364
- onExternalNavigate: async (url, { replace, signal, scroll, departingUrl }) => {
365
- await router.navigate(url, {
366
- replace,
367
- scroll,
368
- _signal: signal,
369
- _skipHistory: true,
370
- _departingUrl: departingUrl,
371
- });
372
- },
362
+ onExternalNavigate: (url, { replace }) => router.navigate(url, { replace }),
373
363
  onTraverse: async (url, scrollY, signal, direction) => {
374
364
  // Back/forward — delegate to the router's popstate handler.
375
365
  await router.handlePopState(url, scrollY, signal, direction);
@@ -380,12 +370,8 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
380
370
  },
381
371
  });
382
372
 
383
- // Wire the router-navigating flag into RouterDeps.
384
- // This must be done after setupNavigationApi returns the controller.
385
- deps.setRouterNavigating = (v) => navApiController!.setRouterNavigating(v);
373
+ // Wired after setupNavigationApi returns the controller.
386
374
  deps.saveNavigationEntryScroll = (y) => navApiController!.saveScrollPosition(y);
387
- deps.completeRouterNavigation = () => navApiController!.completeRouterNavigation();
388
- deps.navigationNavigate = (url, replace) => navApiController!.navigate(url, replace);
389
375
  }
390
376
 
391
377
  /**
@@ -1,93 +1,21 @@
1
1
  /**
2
2
  * Client-side form utilities for server actions.
3
3
  *
4
- * Exports a typed `useActionState` that understands the action builder's result shape.
5
- * Result is typed to:
6
- * { data: T } | { validationErrors: Record<string, string[]> } | { serverError: { code, data? } } | null
7
- *
8
- * The action builder emits a function that satisfies both the direct call signature
9
- * and React's `(prevState, formData) => Promise<State>` contract.
4
+ * Forms use React's own `useActionState`: an action built with
5
+ * `createActionClient` already has the `(prevState, payload)` signature it
6
+ * calls, and the result is typed from it. `parseFormErrors` reads the errors
7
+ * out of that result.
10
8
  *
11
9
  * See design/08-forms-and-actions.md §"Client-Side Form Mechanics"
12
10
  */
13
11
 
14
- import { useActionState as reactUseActionState, useTransition } from 'react';
12
+ import { useTransition } from 'react';
15
13
  import type {
16
14
  ActionFn,
17
15
  ActionResult,
18
16
  InputHint,
19
17
  ValidationErrors,
20
18
  } from '../server/action-client.ts';
21
- import type { FormFlashData } from '../server/form-flash.ts';
22
-
23
- // ─── Types ───────────────────────────────────────────────────────────────
24
-
25
- /**
26
- * The action function type accepted by useActionState.
27
- * Must satisfy React's (prevState, formData) => Promise<State> contract.
28
- */
29
- export type UseActionStateFn<TData> = (
30
- prevState: ActionResult<TData> | null,
31
- formData: FormData
32
- ) => Promise<ActionResult<TData>>;
33
-
34
- /**
35
- * Return type of useActionState.
36
- * [result, formAction, isPending, errors]
37
- * The 4th element is auto-derived from result via useFormErrors logic.
38
- */
39
- export type UseActionStateReturn<TData> = [
40
- result: ActionResult<TData> | null,
41
- formAction: (formData: FormData) => void,
42
- isPending: boolean,
43
- errors: FormErrorsResult,
44
- ];
45
-
46
- // ─── useActionState ──────────────────────────────────────────────────────
47
-
48
- /**
49
- * Typed wrapper around React 19's `useActionState` that understands
50
- * the timber action builder's result shape.
51
- *
52
- * @param action - A server action created with createActionClient or a raw 'use server' function.
53
- * @param initialState - Initial state, typically `null`. Pass `getFormFlash()` for no-JS
54
- * progressive enhancement — the flash seeds the initial state so the form has a
55
- * single source of truth for both with-JS and no-JS paths.
56
- * @param permalink - Optional permalink for progressive enhancement (no-JS fallback URL).
57
- *
58
- * @example
59
- * ```tsx
60
- * 'use client'
61
- * import { useActionState } from '@timber-js/app/client'
62
- * import { createTodo } from './actions'
63
- *
64
- * export function NewTodoForm({ flash }) {
65
- * const [result, action, isPending] = useActionState(createTodo, flash)
66
- * return (
67
- * <form action={action}>
68
- * <input name="title" />
69
- * {result?.validationErrors?.title && <p>{result.validationErrors.title}</p>}
70
- * <button disabled={isPending}>Add</button>
71
- * </form>
72
- * )
73
- * }
74
- * ```
75
- */
76
- export function useActionState<TData>(
77
- action: UseActionStateFn<TData>,
78
- initialState: ActionResult<TData> | FormFlashData | null,
79
- permalink?: string
80
- ): UseActionStateReturn<TData> {
81
- // FormFlashData is structurally compatible with ActionResult at runtime —
82
- // the cast satisfies React's generic inference which would otherwise widen TData.
83
- const [result, formAction, isPending] = reactUseActionState(
84
- action,
85
- initialState as ActionResult<TData> | null,
86
- permalink
87
- );
88
- const errors = deriveFormErrors(result);
89
- return [result, formAction, isPending, errors];
90
- }
91
19
 
92
20
  // ─── useFormAction ───────────────────────────────────────────────────────
93
21
 
@@ -114,12 +42,20 @@ export function useFormAction<TData = unknown, TInput = unknown>(
114
42
  const [isPending, startTransition] = useTransition();
115
43
 
116
44
  const execute = (input?: InputHint<TInput>): Promise<ActionResult<TData>> => {
117
- return new Promise((resolve) => {
45
+ return new Promise((resolve, reject) => {
118
46
  startTransition(async () => {
119
- const result = await (action as (input: InputHint<TInput>) => Promise<ActionResult<TData>>)(
120
- input as InputHint<TInput>
121
- );
122
- resolve(result);
47
+ // A raw server function that throws rejects (TIM-1570). The caller
48
+ // awaiting `execute` gets that rejection; without the catch the
49
+ // promise would never settle.
50
+ try {
51
+ resolve(
52
+ await (action as (input: InputHint<TInput>) => Promise<ActionResult<TData>>)(
53
+ input as InputHint<TInput>
54
+ )
55
+ );
56
+ } catch (error) {
57
+ reject(error);
58
+ }
123
59
  });
124
60
  });
125
61
  };
@@ -129,7 +65,7 @@ export function useFormAction<TData = unknown, TInput = unknown>(
129
65
 
130
66
  // ─── Form error extraction ────────────────────────────────────────────────
131
67
 
132
- /** Return type of the errors element in useActionState. */
68
+ /** What `parseFormErrors` reads out of an action result. */
133
69
  export interface FormErrorsResult {
134
70
  /** Per-field validation errors keyed by field name. */
135
71
  fieldErrors: Record<string, string[]>;
@@ -144,18 +80,19 @@ export interface FormErrorsResult {
144
80
  }
145
81
 
146
82
  /**
147
- * Derive FormErrorsResult from an action result.
148
- * Used internally by useActionState 4th tuple element.
149
- * @internal — exported for test access only.
83
+ * Read the errors out of an action result — the state `useActionState`
84
+ * returns. `_root` validation errors are form-level; every other key is a
85
+ * field.
86
+ *
87
+ * @example
88
+ * ```tsx
89
+ * const [result, action, isPending] = useActionState(createTodo, null)
90
+ * const errors = parseFormErrors(result)
91
+ * errors.getFieldError('title') // first message for the field, or null
92
+ * ```
150
93
  */
151
- export function deriveFormErrors<TData>(
152
- result:
153
- | ActionResult<TData>
154
- | {
155
- validationErrors?: ValidationErrors;
156
- serverError?: { code: string; data?: Record<string, unknown> };
157
- }
158
- | null
94
+ export function parseFormErrors<TData>(
95
+ result: ActionResult<TData> | null | undefined
159
96
  ): FormErrorsResult {
160
97
  const empty: FormErrorsResult = {
161
98
  fieldErrors: {},
@@ -167,10 +104,8 @@ export function deriveFormErrors<TData>(
167
104
 
168
105
  if (!result) return empty;
169
106
 
170
- const validationErrors = result.validationErrors as ValidationErrors | undefined;
171
- const serverError = result.serverError as
172
- | { code: string; data?: Record<string, unknown> }
173
- | undefined;
107
+ const validationErrors: ValidationErrors | undefined = result.validationErrors;
108
+ const serverError = result.serverError;
174
109
 
175
110
  if (!validationErrors && !serverError) return empty;
176
111
 
@@ -75,8 +75,8 @@ export {
75
75
  } from './use-selected-layout-segment.ts';
76
76
 
77
77
  // Forms
78
- export { useActionState, useFormAction } from './form.tsx';
79
- export type { UseActionStateFn, UseActionStateReturn, FormErrorsResult } from './form.tsx';
78
+ export { parseFormErrors, useFormAction } from './form.tsx';
79
+ export type { FormErrorsResult } from './form.tsx';
80
80
 
81
81
  // Params. Called with no argument this returns the untyped accumulated params;
82
82
  // pass the route's SEGMENT_PATH (from its generated `./$segment` module) to get
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * Navigation API integration — progressive enhancement for client navigation.
3
3
  *
4
- * When the Navigation API (`window.navigation`) is available, this module
5
- * provides an intercept-based navigation model that replaces the separate
6
- * popstate + click handler approach with a single navigate event listener.
7
- *
8
- * Key benefits:
9
- * - Intercepts ALL navigations (link clicks, form submissions, back/forward)
10
- * - Built-in AbortSignal per navigation (auto-aborts in-flight fetches)
11
- * - Per-entry state via NavigationHistoryEntry.getState()
12
- * - navigation.transition for progress tracking
4
+ * When the Navigation API (`window.navigation`) is available, a single
5
+ * navigate event listener replaces the popstate handler and routes plain
6
+ * `<a>` clicks through the router:
7
+ * - Traversals are intercepted, with the event's AbortSignal linked to the
8
+ * router's fetch
9
+ * - Cross-document push/replace navigations are cancelled and re-run through
10
+ * the router, so the URL commits with the tree (never at intercept time)
11
+ * - Per-entry scroll state via NavigationHistoryEntry.getState()
13
12
  *
14
13
  * When unavailable, all functions are no-ops and the History API fallback
15
14
  * in browser-entry.ts handles navigation.
@@ -67,20 +66,18 @@ export function traverseDirection(
67
66
  /**
68
67
  * Callbacks for the Navigation API event handler.
69
68
  *
70
- * When the Navigation API intercepts a navigation, it delegates to these
71
- * callbacks which run the RSC fetch + render pipeline.
69
+ * Push/replace navigations are handed to the router, which commits the URL
70
+ * with the tree. Traversals are intercepted and replayed or fetched.
72
71
  */
73
72
  export interface NavigationApiCallbacks {
74
73
  /**
75
- * Handle a push/replace navigation intercepted by the Navigation API.
76
- * This covers both Link <a> clicks (user-initiated) and external
77
- * navigations (plain <a> tags, programmatic).
78
- * The Navigation API handles the URL update via event.intercept().
74
+ * Handle a cross-document push/replace navigation the router did not start
75
+ * itself: a plain `<a>`, `navigation.navigate()`, or `location.assign()`.
76
+ * The handler has already cancelled the browser's navigation, so the
77
+ * address bar has not moved; the router moves it when the destination
78
+ * commits, as it does for its own navigations.
79
79
  */
80
- onExternalNavigate: (
81
- url: string,
82
- options: { replace: boolean; signal: AbortSignal; scroll?: boolean; departingUrl?: string }
83
- ) => Promise<void>;
80
+ onExternalNavigate: (url: string, options: { replace: boolean }) => Promise<void>;
84
81
 
85
82
  /**
86
83
  * Handle a traversal (back/forward button). The Navigation API intercepts
@@ -104,58 +101,15 @@ export interface NavigationApiCallbacks {
104
101
  }
105
102
 
106
103
  /**
107
- * Controller returned by setupNavigationApi. Provides methods to
108
- * coordinate between the router and the navigate event listener.
104
+ * Controller returned by setupNavigationApi.
109
105
  */
110
106
  export interface NavigationApiController {
111
- /**
112
- * Set the router-navigating flag. When `true`, the next navigate event
113
- * (from pushState/replaceState) is recognized as router-initiated. The
114
- * handler still intercepts it — but ties the browser's native loading
115
- * state to a deferred promise instead of running the RSC pipeline again.
116
- *
117
- * This means `navigation.transition` is active for the full duration of
118
- * every router-initiated navigation, giving the browser a native loading
119
- * indicator (tab spinner, address bar) aligned with the TopLoader.
120
- *
121
- * Must be called synchronously around pushState/replaceState:
122
- * controller.setRouterNavigating(true);
123
- * history.pushState(...); // navigate event fires, intercepted
124
- * controller.setRouterNavigating(false); // flag off, deferred stays open
125
- */
126
- setRouterNavigating: (value: boolean) => void;
127
-
128
- /**
129
- * Resolve the deferred promise created by setRouterNavigating(true),
130
- * clearing the browser's native loading state. Call this when the
131
- * navigation fully completes — the same finally block in router.navigate
132
- * that clears the router's pending store.
133
- */
134
- completeRouterNavigation: () => void;
135
-
136
- /**
137
- * Initiate a navigation via the Navigation API (`navigation.navigate()`).
138
- * Unlike `history.pushState()`, this fires the navigate event BEFORE
139
- * committing the URL — allowing Chrome to show its native loading
140
- * indicator while the intercept handler runs.
141
- *
142
- * Must be called with setRouterNavigating(true) active so the handler
143
- * recognizes it as router-initiated and uses the deferred promise.
144
- */
145
- navigate: (url: string, replace: boolean) => void;
146
-
147
107
  /**
148
108
  * Save scroll position into the current navigation entry's state.
149
109
  * Uses navigation.updateCurrentEntry() for per-entry scroll storage.
150
110
  */
151
111
  saveScrollPosition: (scrollY: number) => void;
152
112
 
153
- /**
154
- * Check if the Navigation API has an active transition.
155
- * Returns the transition object if available, null otherwise.
156
- */
157
- hasActiveTransition: () => boolean;
158
-
159
113
  /** Remove the navigate event listener. */
160
114
  cleanup: () => void;
161
115
  }
@@ -163,24 +117,19 @@ export interface NavigationApiController {
163
117
  /**
164
118
  * Set up the Navigation API navigate event listener.
165
119
  *
166
- * Intercepts same-origin navigations and delegates to the provided callbacks.
167
- * Router-initiated navigations (pushState from router.navigate) are detected
168
- * via a synchronous flag and NOT intercepted — the router already handles them.
169
- *
170
- * Returns a controller for coordinating with the router.
120
+ * The address bar moves only when the destination's tree commits
121
+ * (design/19-client-navigation.md §"prepareNavigation"). `event.intercept()`
122
+ * would commit the URL as soon as it is called, a full round trip before the
123
+ * page that belongs to it, so push/replace navigations are never
124
+ * intercepted: the router's own navigations never reach here as
125
+ * cross-document events (Link cancels the click and the router commits with
126
+ * `pushState`), and the rest are cancelled and re-run through the router.
127
+ * Only traversals — which the browser has already moved the URL for in every
128
+ * browser — and shallow updates are intercepted.
171
129
  */
172
130
  export function setupNavigationApi(callbacks: NavigationApiCallbacks): NavigationApiController {
173
131
  const nav = getNavigationApi()!;
174
132
 
175
- let routerNavigating = false;
176
-
177
- // Deferred promise for router-initiated navigations. Created when
178
- // setRouterNavigating(true) is called, resolved by completeRouterNavigation().
179
- // The navigate event handler intercepts with this promise so the browser's
180
- // native loading state (tab spinner) stays active until the navigation
181
- // completes — the same lifecycle the TopLoader is driven by.
182
- let routerNavDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
183
-
184
133
  function handleNavigate(event: NavigateEvent): void {
185
134
  // Skip non-interceptable navigations (cross-origin, etc.)
186
135
  if (!event.canIntercept) return;
@@ -234,20 +183,6 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
234
183
  const destUrl = new URL(event.destination.url);
235
184
  if (destUrl.origin !== location.origin) return;
236
185
 
237
- // Router-initiated navigation (Link click → router.navigate → pushState).
238
- // The router is already running the RSC pipeline — don't run it again.
239
- // Instead, intercept with the deferred promise so the browser's native
240
- // loading state tracks the navigation's full lifecycle. This aligns the
241
- // tab spinner / address bar indicator with the TopLoader.
242
- if (routerNavigating && routerNavDeferred) {
243
- event.intercept({
244
- scroll: 'manual',
245
- focusReset: 'manual',
246
- handler: () => routerNavDeferred!.promise,
247
- });
248
- return;
249
- }
250
-
251
186
  // Skip reload navigations — let the browser handle full page reload
252
187
  if (event.navigationType === 'reload') return;
253
188
 
@@ -274,90 +209,33 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
274
209
  await callbacks.onTraverse(url, scrollY, event.signal, direction);
275
210
  },
276
211
  });
277
- } else if (event.navigationType === 'push' || event.navigationType === 'replace') {
278
- // Push/replace — a Link <a> click or an external navigation
279
- // (plain <a> tag, programmatic).
280
-
281
- // Save the departing page's scroll position BEFORE event.intercept()
282
- // commits the URL change. Once intercept() is called, currentEntry
283
- // switches to the new (destination) entry — any updateCurrentEntry()
284
- // call after that would save to the wrong entry.
285
- // See: router.navigate() also calls saveNavigationEntryScroll(), but
286
- // for Navigation API <a> click navigations (where Link does NOT call
287
- // router.navigate directly), the router's save runs inside the
288
- // intercept handler — too late, currentEntry has already switched.
289
- try {
290
- const currentState = (nav.currentEntry?.getState() ?? {}) as Record<string, unknown>;
291
- nav.updateCurrentEntry({
292
- state: { ...currentState, timber: true, scrollY: window.scrollY },
293
- });
294
- } catch {
295
- // Ignore — entry may be disposed
296
- }
297
-
298
- // Capture the departing URL BEFORE event.intercept() commits the
299
- // destination. Once intercept() is called, currentEntry switches and
300
- // getCurrentUrl() returns the destination (TIM-1232).
301
- const departingUrl = nav.currentEntry?.url
302
- ? new URL(nav.currentEntry.url).pathname +
303
- stripRscCacheKey(new URL(nav.currentEntry.url).search)
304
- : undefined;
305
-
306
- event.intercept({
307
- scroll: 'manual',
308
- focusReset: 'manual',
309
- async handler() {
310
- await callbacks.onExternalNavigate(url + destUrl.hash, {
311
- replace: event.navigationType === 'replace',
312
- signal: event.signal,
313
- scroll: undefined,
314
- departingUrl,
315
- });
316
- },
317
- });
212
+ return;
318
213
  }
214
+
215
+ // A same-document push/replace is `history.pushState()` /
216
+ // `replaceState()`: the router's own commit, or app code (nuqs,
217
+ // replaceUrl, a third-party library). The URL is already the
218
+ // destination and the History API patch in router-init syncs the search
219
+ // params, exactly as in browsers without the Navigation API.
220
+ if (event.destination.sameDocument) return;
221
+
222
+ // A cross-document push/replace the router did not start: a plain
223
+ // `<a>`, `navigation.navigate()`, `location.assign()`. Cancel it and
224
+ // re-run it through the router, which commits the URL with the tree.
225
+ // A navigation the browser will not let us cancel stays a document load.
226
+ // Cancelling rejects a `navigation.navigate()` caller's `committed` and
227
+ // `finished` with AbortError and drops its `state`; the router's own API
228
+ // is `useRouter()` (design/19 §"The address bar moves on commit").
229
+ if (!event.cancelable) return;
230
+ event.preventDefault();
231
+ void callbacks.onExternalNavigate(url + destUrl.hash, {
232
+ replace: event.navigationType === 'replace',
233
+ });
319
234
  }
320
235
 
321
236
  nav.addEventListener('navigate', handleNavigate);
322
237
 
323
238
  return {
324
- setRouterNavigating(value: boolean): void {
325
- routerNavigating = value;
326
- if (value) {
327
- // Create a new deferred promise. The navigate event handler will
328
- // intercept and tie the browser's loading state to this promise.
329
- let resolve!: () => void;
330
- const promise = new Promise<void>((r) => {
331
- resolve = r;
332
- });
333
- routerNavDeferred = { promise, resolve };
334
- } else {
335
- // Flag off — but DON'T resolve the deferred here. The navigation
336
- // is still in flight (RSC fetch + render). completeRouterNavigation()
337
- // resolves it when the navigation fully completes.
338
- routerNavigating = false;
339
- }
340
- },
341
-
342
- completeRouterNavigation(): void {
343
- if (routerNavDeferred) {
344
- routerNavDeferred.resolve();
345
- routerNavDeferred = null;
346
- }
347
- },
348
-
349
- navigate(url: string, replace: boolean): void {
350
- // Use navigation.navigate() instead of history.pushState().
351
- // This fires the navigate event BEFORE committing the URL,
352
- // which lets Chrome show its native loading indicator while
353
- // the intercept handler (deferred promise) is pending.
354
- // history.pushState() commits the URL synchronously, so Chrome
355
- // sees the navigation as already complete and skips the indicator.
356
- nav.navigate(url, {
357
- history: replace ? 'replace' : 'push',
358
- });
359
- },
360
-
361
239
  saveScrollPosition(scrollY: number): void {
362
240
  try {
363
241
  const currentState = (nav.currentEntry?.getState() ?? {}) as Record<string, unknown>;
@@ -369,10 +247,6 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
369
247
  }
370
248
  },
371
249
 
372
- hasActiveTransition(): boolean {
373
- return nav.transition != null;
374
- },
375
-
376
250
  cleanup(): void {
377
251
  nav.removeEventListener('navigate', handleNavigate);
378
252
  },
@@ -288,8 +288,8 @@ export function navigateTransition(
288
288
  // the destination reveals as React is able to render it
289
289
  // rather than waiting for the whole Flight stream. The await is here so
290
290
  // the promise this function hands back still means "the payload is
291
- // decoded", which is what the router's scroll restoration, the
292
- // Navigation API deferred and `<Link>`'s `isPending` are timed against.
291
+ // decoded", which is what the router's scroll restoration and
292
+ // `<Link>`'s `isPending` are timed against.
293
293
  //
294
294
  // ...unless this navigation loses first, in which case it stops waiting
295
295
  // on a stream that is no longer its business.