@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.
- package/agent-skill.md +10 -5
- package/dist/_chunks/{actions-Rjk4htmA.js → actions-CCdnVtWm.js} +8 -6
- package/dist/_chunks/actions-CCdnVtWm.js.map +1 -0
- package/dist/_chunks/{als-registry-DaxkVjt5.js → als-registry-BZqHCtq-.js} +2 -4
- package/dist/_chunks/als-registry-BZqHCtq-.js.map +1 -0
- package/dist/_chunks/{cache-api-DGdYfNJn.js → cache-api-LA3sBpUS.js} +5 -5
- package/dist/_chunks/{cache-api-DGdYfNJn.js.map → cache-api-LA3sBpUS.js.map} +1 -1
- package/dist/_chunks/{chains-DGX9zmg9.js → chains-BfoPFraI.js} +2 -2
- package/dist/_chunks/{chains-DGX9zmg9.js.map → chains-BfoPFraI.js.map} +1 -1
- package/dist/_chunks/{classify-QwG5rxKI.js → classify-BT66U83D.js} +2 -2
- package/dist/_chunks/{classify-QwG5rxKI.js.map → classify-BT66U83D.js.map} +1 -1
- package/dist/_chunks/{cli-check-ajNY3B2e.js → cli-check-BfQ54-UJ.js} +3 -3
- package/dist/_chunks/{cli-check-ajNY3B2e.js.map → cli-check-BfQ54-UJ.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js → cli-schema-sync-czh2dsLs.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js.map → cli-schema-sync-czh2dsLs.js.map} +1 -1
- package/dist/_chunks/{client-dep-entries-2HCF09no.js → client-dep-entries-CQwpb8dI.js} +2 -2
- package/dist/_chunks/{client-dep-entries-2HCF09no.js.map → client-dep-entries-CQwpb8dI.js.map} +1 -1
- package/dist/_chunks/{convention-lint-DLmhGsRS.js → convention-lint-BEVW4EID.js} +3 -3
- package/dist/_chunks/{convention-lint-DLmhGsRS.js.map → convention-lint-BEVW4EID.js.map} +1 -1
- package/dist/_chunks/{dev-server-TFpEwm3H.js → dev-server-FKxptbnI.js} +2 -2
- package/dist/_chunks/{dev-server-TFpEwm3H.js.map → dev-server-FKxptbnI.js.map} +1 -1
- package/dist/_chunks/{json-lossy-check-CVuRs2hG.js → json-lossy-check-C8zBY2uZ.js} +2 -2
- package/dist/_chunks/{json-lossy-check-CVuRs2hG.js.map → json-lossy-check-C8zBY2uZ.js.map} +1 -1
- package/dist/_chunks/{live-graph-cNuWMYQI.js → live-graph-C_4v-fHv.js} +4 -4
- package/dist/_chunks/{live-graph-cNuWMYQI.js.map → live-graph-C_4v-fHv.js.map} +1 -1
- package/dist/_chunks/{logger-BP0LN6vP.js → logger-CbLdcy-W.js} +2 -2
- package/dist/_chunks/{logger-BP0LN6vP.js.map → logger-CbLdcy-W.js.map} +1 -1
- package/dist/_chunks/{poison-scan-CfQ3unZR.js → poison-scan-C92liMAr.js} +2 -2
- package/dist/_chunks/{poison-scan-CfQ3unZR.js.map → poison-scan-C92liMAr.js.map} +1 -1
- package/dist/_chunks/{scanner-DmqdxzbW.js → scanner-CQt12vE2.js} +2 -2
- package/dist/_chunks/{scanner-DmqdxzbW.js.map → scanner-CQt12vE2.js.map} +1 -1
- package/dist/_chunks/{sizeof-BM1409x2.js → sizeof-QPE5nd3u.js} +2 -2
- package/dist/_chunks/{sizeof-BM1409x2.js.map → sizeof-QPE5nd3u.js.map} +1 -1
- package/dist/_chunks/{walkers-Czu2jXFq.js → walkers-DAT4avhZ.js} +3 -3
- package/dist/_chunks/{walkers-Czu2jXFq.js.map → walkers-DAT4avhZ.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/analyze/crawl-entry.js +3 -3
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cache/index.js +2 -2
- package/dist/cache/stores/memory.js +1 -1
- package/dist/cdn/workers-cache-purge.js +1 -1
- package/dist/cli.js +3 -3
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/form.d.ts +17 -64
- package/dist/client/form.d.ts.map +1 -1
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +23 -51
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +25 -25
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-api.d.ts +25 -63
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/router-lifecycle.d.ts +17 -11
- package/dist/client/router-lifecycle.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +0 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +14 -45
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/index.js +6 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/server/action-client.d.ts +9 -5
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts +27 -8
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +22 -2
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/client-error-message.d.ts +12 -0
- package/dist/server/client-error-message.d.ts.map +1 -0
- package/dist/server/form-state-embed.d.ts +32 -0
- package/dist/server/form-state-embed.d.ts.map +1 -0
- package/dist/server/index.d.ts +0 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +7 -40
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +10 -6
- package/dist/server/internal.js.map +1 -1
- package/dist/server/logger.d.ts +1 -0
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +20 -6
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +27 -2
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-dispatcher.d.ts +6 -5
- package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +8 -0
- package/dist/server/ssr-bridge-types.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +3 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/docs/api/31-api-client.mdx +9 -3
- package/docs/learn/08-forms-and-actions.mdx +21 -28
- package/package.json +1 -1
- package/src/client/browser-entry/action-dispatch.ts +74 -18
- package/src/client/browser-entry/hydrate.ts +18 -0
- package/src/client/browser-entry/router-init.ts +4 -18
- package/src/client/form.tsx +33 -98
- package/src/client/index.ts +2 -2
- package/src/client/navigation-api.ts +47 -173
- package/src/client/navigation-transition.ts +2 -2
- package/src/client/router-lifecycle.ts +40 -20
- package/src/client/router-pipeline.ts +3 -9
- package/src/client/router-types.ts +14 -49
- package/src/client/router.ts +38 -58
- package/src/plugins/shims.ts +0 -2
- package/src/rsc-runtime/rsc.ts +3 -0
- package/src/rsc-runtime/vendor-types.d.ts +14 -0
- package/src/server/action-client.ts +18 -20
- package/src/server/action-handler.ts +133 -95
- package/src/server/als-registry.ts +23 -9
- package/src/server/client-error-message.ts +18 -0
- package/src/server/form-state-embed.ts +63 -0
- package/src/server/index.ts +0 -4
- package/src/server/logger.ts +6 -1
- package/src/server/pipeline.ts +27 -8
- package/src/server/request-context.ts +39 -2
- package/src/server/route-element-builder.ts +12 -1
- package/src/server/rsc-entry/action-dispatcher.ts +37 -34
- package/src/server/rsc-entry/error-renderer.ts +2 -1
- package/src/server/rsc-entry/rsc-stream.ts +20 -11
- package/src/server/rsc-entry/ssr-renderer.ts +14 -1
- package/src/server/ssr-bridge-types.ts +9 -0
- package/src/server/ssr-entry.ts +1 -0
- package/src/server/ssr-render.ts +5 -0
- package/dist/_chunks/actions-Rjk4htmA.js.map +0 -1
- package/dist/_chunks/als-registry-DaxkVjt5.js.map +0 -1
- package/dist/server/form-flash.d.ts +0 -78
- package/dist/server/form-flash.d.ts.map +0 -1
- 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
|
-
//
|
|
163
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
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
|
/**
|
package/src/client/form.tsx
CHANGED
|
@@ -1,93 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-side form utilities for server actions.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
|
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
|
|
171
|
-
const serverError = result.serverError
|
|
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
|
|
package/src/client/index.ts
CHANGED
|
@@ -75,8 +75,8 @@ export {
|
|
|
75
75
|
} from './use-selected-layout-segment.ts';
|
|
76
76
|
|
|
77
77
|
// Forms
|
|
78
|
-
export {
|
|
79
|
-
export type {
|
|
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,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
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
|
-
*
|
|
71
|
-
*
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
-
|
|
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
|
|
292
|
-
//
|
|
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.
|