@timber-js/app 0.2.0-alpha.212 → 0.2.0-alpha.214
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/dist/_chunks/{actions-CCdnVtWm.js → actions-CUh3cClk.js} +43 -8
- package/dist/_chunks/{actions-CCdnVtWm.js.map → actions-CUh3cClk.js.map} +1 -1
- package/dist/_chunks/{canonicalize-CgHoscYO.js → canonicalize-BAWkWiKK.js} +28 -2
- package/dist/_chunks/{canonicalize-CgHoscYO.js.map → canonicalize-BAWkWiKK.js.map} +1 -1
- package/dist/_chunks/{chains-BfoPFraI.js → chains-CjK1Eu6a.js} +2 -2
- package/dist/_chunks/{chains-BfoPFraI.js.map → chains-CjK1Eu6a.js.map} +1 -1
- package/dist/_chunks/{classify-BT66U83D.js → classify-GAdt6aiA.js} +2 -2
- package/dist/_chunks/{classify-BT66U83D.js.map → classify-GAdt6aiA.js.map} +1 -1
- package/dist/_chunks/{cli-check-BfQ54-UJ.js → cli-check-DOzjlycg.js} +3 -3
- package/dist/_chunks/{cli-check-BfQ54-UJ.js.map → cli-check-DOzjlycg.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-czh2dsLs.js → cli-schema-sync-B9wDaQvW.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-czh2dsLs.js.map → cli-schema-sync-B9wDaQvW.js.map} +1 -1
- package/dist/_chunks/{client-dep-entries-CQwpb8dI.js → client-dep-entries-DyDqXOF9.js} +2 -2
- package/dist/_chunks/{client-dep-entries-CQwpb8dI.js.map → client-dep-entries-DyDqXOF9.js.map} +1 -1
- package/dist/_chunks/{convention-lint-BEVW4EID.js → convention-lint-B3QEGJX7.js} +2 -2
- package/dist/_chunks/{convention-lint-BEVW4EID.js.map → convention-lint-B3QEGJX7.js.map} +1 -1
- package/dist/_chunks/{dev-server-FKxptbnI.js → dev-server-_9L4KWC-.js} +3 -3
- package/dist/_chunks/{dev-server-FKxptbnI.js.map → dev-server-_9L4KWC-.js.map} +1 -1
- package/dist/_chunks/{error-boundary-DsNScGRM.js → error-boundary-xxxLtXt6.js} +38 -5
- package/dist/_chunks/{error-boundary-DsNScGRM.js.map → error-boundary-xxxLtXt6.js.map} +1 -1
- package/dist/_chunks/{live-graph-C_4v-fHv.js → live-graph-Cd3YHvrH.js} +4 -4
- package/dist/_chunks/{live-graph-C_4v-fHv.js.map → live-graph-Cd3YHvrH.js.map} +1 -1
- package/dist/_chunks/{poison-scan-C92liMAr.js → poison-scan-BnJjBOkn.js} +2 -2
- package/dist/_chunks/{poison-scan-C92liMAr.js.map → poison-scan-BnJjBOkn.js.map} +1 -1
- package/dist/_chunks/{scanner-CQt12vE2.js → scanner-CKAT5gRx.js} +2 -2
- package/dist/_chunks/{scanner-CQt12vE2.js.map → scanner-CKAT5gRx.js.map} +1 -1
- package/dist/_chunks/{walkers-DAT4avhZ.js → walkers-DwCXEyRu.js} +3 -3
- package/dist/_chunks/{walkers-DAT4avhZ.js.map → walkers-DwCXEyRu.js.map} +1 -1
- package/dist/adapters/nitro-preview.d.ts.map +1 -1
- package/dist/adapters/nitro.js +11 -2
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/analyze/crawl-entry.js +3 -3
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cli.js +3 -3
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/action-queue.d.ts +1 -0
- package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
- package/dist/client/browser-entry/form-state.d.ts +22 -0
- package/dist/client/browser-entry/form-state.d.ts.map +1 -0
- package/dist/client/browser-entry/hydrate.d.ts +9 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/index.d.ts +2 -0
- package/dist/client/browser-entry/index.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +90 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +26 -10
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-transition.d.ts +11 -2
- package/dist/client/navigation-transition.d.ts.map +1 -1
- package/dist/client/router-effects.d.ts +70 -8
- package/dist/client/router-effects.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +3 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +12 -1
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/use-form-field.d.ts +39 -0
- package/dist/client/use-form-field.d.ts.map +1 -0
- package/dist/config-types.d.ts +2 -1
- package/dist/config-types.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/action-client.d.ts +11 -2
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/canonicalize.d.ts +22 -0
- package/dist/server/canonicalize.d.ts.map +1 -1
- package/dist/server/flight-scripts.d.ts +9 -0
- package/dist/server/flight-scripts.d.ts.map +1 -1
- package/dist/server/form-data.d.ts +13 -4
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/form-state-flight.d.ts +32 -0
- package/dist/server/form-state-flight.d.ts.map +1 -0
- package/dist/server/index.js +37 -23
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +54 -3
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-helpers.d.ts +31 -0
- package/dist/server/pipeline-helpers.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts +2 -0
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +6 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +7 -5
- 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-form-state.d.ts +30 -0
- package/dist/server/ssr-form-state.d.ts.map +1 -0
- package/dist/shared/form-state-flight.d.ts +36 -0
- package/dist/shared/form-state-flight.d.ts.map +1 -0
- package/docs/api/31-api-client.mdx +22 -0
- package/docs/api/34-api-config.mdx +1 -1
- package/docs/learn/08-forms-and-actions.mdx +109 -22
- package/package.json +1 -1
- package/src/adapters/nitro-preview.ts +10 -1
- package/src/client/browser-entry/action-dispatch.ts +34 -10
- package/src/client/browser-entry/action-queue.ts +1 -1
- package/src/client/browser-entry/form-state.ts +48 -0
- package/src/client/browser-entry/hydrate.ts +9 -18
- package/src/client/browser-entry/index.ts +25 -7
- package/src/client/browser-entry/router-init.ts +3 -2
- package/src/client/index.ts +1 -0
- package/src/client/navigation-transition.ts +13 -2
- package/src/client/router-effects.ts +99 -10
- package/src/client/router-pipeline.ts +7 -3
- package/src/client/router-types.ts +12 -1
- package/src/client/router.ts +35 -6
- package/src/client/use-form-field.ts +132 -0
- package/src/config-types.ts +2 -1
- package/src/server/action-client.ts +68 -53
- package/src/server/canonicalize.ts +29 -0
- package/src/server/flight-scripts.ts +13 -0
- package/src/server/form-data.ts +62 -10
- package/src/server/form-state-flight.ts +67 -0
- package/src/server/pipeline-helpers.ts +61 -0
- package/src/server/pipeline.ts +13 -1
- package/src/server/rsc-entry/action-dispatcher.ts +3 -0
- package/src/server/rsc-entry/index.ts +1 -0
- package/src/server/rsc-entry/render-route.ts +16 -0
- package/src/server/rsc-entry/ssr-renderer.ts +12 -14
- package/src/server/ssr-bridge-types.ts +7 -6
- package/src/server/ssr-entry.ts +16 -3
- package/src/server/ssr-form-state.ts +58 -0
- package/src/shared/form-state-flight.ts +74 -0
- package/dist/server/form-state-embed.d.ts +0 -32
- package/dist/server/form-state-embed.d.ts.map +0 -1
- package/src/server/form-state-embed.ts +0 -63
|
@@ -76,6 +76,13 @@ interface HydrateOptions {
|
|
|
76
76
|
reactRoot: ReactRootHost;
|
|
77
77
|
/** Makes the page current and builds its tree — see `RouterInitResult.hydrate`. */
|
|
78
78
|
hydrate: RouterInitResult['hydrate'];
|
|
79
|
+
/**
|
|
80
|
+
* The decoded form state of the no-JS action this page answers, or `null`
|
|
81
|
+
* (./form-state.ts). `hydrateRoot` needs the value Fizz rendered with, or
|
|
82
|
+
* the `useActionState` hook that submitted hydrates back to its initial
|
|
83
|
+
* state. See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
|
|
84
|
+
*/
|
|
85
|
+
formState: ReactFormState | null;
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
/**
|
|
@@ -90,22 +97,6 @@ function takeEmbeddedSegmentInfo(): SegmentInfo[] | null {
|
|
|
90
97
|
return Array.isArray(embedded) ? embedded : null;
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
/**
|
|
94
|
-
* Take the form state the server embedded (`self.__timber_form_state`) when
|
|
95
|
-
* this page answers a no-JS action, and remove it. `hydrateRoot` needs the
|
|
96
|
-
* value Fizz rendered with, or the `useActionState` hook that submitted
|
|
97
|
-
* hydrates back to its initial state. See design/08-forms-and-actions.md
|
|
98
|
-
* §"No-JS Result Round-Trip".
|
|
99
|
-
*/
|
|
100
|
-
function takeEmbeddedFormState(): ReactFormState | null {
|
|
101
|
-
const embedded: unknown = Reflect.get(self, '__timber_form_state');
|
|
102
|
-
Reflect.deleteProperty(self, '__timber_form_state');
|
|
103
|
-
// `ReactFormState` is an opaque type: React builds it (`decodeFormState`
|
|
104
|
-
// on the server) and only React reads it, so there is no shape to check
|
|
105
|
-
// beyond the tuple. It is our own server's JSON of that value.
|
|
106
|
-
return Array.isArray(embedded) ? (embedded as unknown as ReactFormState) : null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
100
|
/**
|
|
110
101
|
* Make the server-rendered page current, and hydrate the React tree with it
|
|
111
102
|
* when an RSC payload is available.
|
|
@@ -122,7 +113,7 @@ function takeEmbeddedFormState(): ReactFormState | null {
|
|
|
122
113
|
* first navigation or revalidation, creates it with the tree it renders
|
|
123
114
|
* (`createReactRoot`, TIM-600 / TIM-580).
|
|
124
115
|
*/
|
|
125
|
-
export function hydrateApp({ rscResult, reactRoot, hydrate }: HydrateOptions): void {
|
|
116
|
+
export function hydrateApp({ rscResult, reactRoot, hydrate, formState }: HydrateOptions): void {
|
|
126
117
|
// The chain is the router's own `renderTree`, not a copy: an element type
|
|
127
118
|
// that is present here and absent on the first navigation (or the
|
|
128
119
|
// reverse) changes the type at that position, and React remounts
|
|
@@ -143,7 +134,7 @@ export function hydrateApp({ rscResult, reactRoot, hydrate }: HydrateOptions): v
|
|
|
143
134
|
// construction when the tree it builds renders.
|
|
144
135
|
hydrate(page, (element) =>
|
|
145
136
|
reactRoot.hydrate(element, {
|
|
146
|
-
formState
|
|
137
|
+
formState,
|
|
147
138
|
// Suppress recoverable hydration errors from deny/error signals
|
|
148
139
|
// inside Suspense boundaries. The server already handled these
|
|
149
140
|
// (wrapStreamWithErrorHandling closes the stream cleanly after
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* Bootstrap call order contract:
|
|
16
16
|
*
|
|
17
17
|
* 1. setupServerActions() — register callServer (independent)
|
|
18
|
+
* takeEmbeddedFormState() — only on a page answering a no-JS action:
|
|
19
|
+
* decode its form state, and run 2–7 after
|
|
18
20
|
* 2. createRscPayloadStream() — decode inlined RSC payload
|
|
19
21
|
* 3. createReactRoot() + — the root host the router renders through,
|
|
20
22
|
* createTimberRouter() then the router + Navigation API
|
|
@@ -41,9 +43,11 @@ import { initStaleClient } from '../stale-client.ts';
|
|
|
41
43
|
|
|
42
44
|
import { setupServerActions } from './action-dispatch.ts';
|
|
43
45
|
import { createRscPayloadStream } from './rsc-stream.ts';
|
|
46
|
+
import { takeEmbeddedFormState } from './form-state.ts';
|
|
44
47
|
import { createTimberRouter } from './router-init.ts';
|
|
45
48
|
import { createReactRoot } from '../react-root.ts';
|
|
46
49
|
import type { TopLoaderConfig } from '../top-loader.tsx';
|
|
50
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
47
51
|
import { hydrateApp } from './hydrate.ts';
|
|
48
52
|
import { setupPostHydration } from './post-hydration.ts';
|
|
49
53
|
import { setupHmr } from './hmr.ts';
|
|
@@ -54,7 +58,7 @@ setupServerActions();
|
|
|
54
58
|
|
|
55
59
|
// ─── 2–6. Bootstrap ─────────────────────────────────────────────
|
|
56
60
|
|
|
57
|
-
function bootstrap(runtimeConfig: typeof config): void {
|
|
61
|
+
function bootstrap(runtimeConfig: typeof config, formState: ReactFormState | null): void {
|
|
58
62
|
// Initialize deployment ID for version skew detection (TIM-446).
|
|
59
63
|
// In dev mode this is null — skew checks are skipped.
|
|
60
64
|
const deploymentId = (runtimeConfig as Record<string, unknown>).deploymentId as string | null;
|
|
@@ -95,7 +99,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
95
99
|
});
|
|
96
100
|
|
|
97
101
|
// Step 4: Make the page current and hydrate (no root without a payload)
|
|
98
|
-
hydrateApp({ rscResult, reactRoot, hydrate });
|
|
102
|
+
hydrateApp({ rscResult, reactRoot, hydrate, formState });
|
|
99
103
|
|
|
100
104
|
// Step 5: Post-hydration wiring
|
|
101
105
|
setupPostHydration({ router, navApiController });
|
|
@@ -104,8 +108,6 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
104
108
|
setupHmr(router);
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
bootstrap(config);
|
|
108
|
-
|
|
109
111
|
// ─── 7. Ready Signal ────────────────────────────────────────────
|
|
110
112
|
|
|
111
113
|
// Signal that the client runtime has been initialized.
|
|
@@ -115,6 +117,22 @@ bootstrap(config);
|
|
|
115
117
|
// via hydrateRoot(document, ...), mutating <html> attributes causes
|
|
116
118
|
// hydration mismatch warnings. Dynamically-added <meta> tags don't
|
|
117
119
|
// conflict because React doesn't reconcile them.
|
|
118
|
-
|
|
119
|
-
readyMeta
|
|
120
|
-
|
|
120
|
+
function signalReady(): void {
|
|
121
|
+
const readyMeta = document.createElement('meta');
|
|
122
|
+
readyMeta.name = 'timber-ready';
|
|
123
|
+
document.head.appendChild(readyMeta);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A page answering a no-JS action must hydrate with the form state Fizz
|
|
127
|
+
// rendered, which is decoded first (design/08 §"No-JS Result Round-Trip").
|
|
128
|
+
// Every other page bootstraps synchronously, as it always has.
|
|
129
|
+
const embeddedFormState = takeEmbeddedFormState();
|
|
130
|
+
if (embeddedFormState) {
|
|
131
|
+
void embeddedFormState.then((formState) => {
|
|
132
|
+
bootstrap(config, formState);
|
|
133
|
+
signalReady();
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
bootstrap(config, null);
|
|
137
|
+
signalReady();
|
|
138
|
+
}
|
|
@@ -297,7 +297,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
297
297
|
//
|
|
298
298
|
// `_url` names the pending URL the router already published to its own
|
|
299
299
|
// pending store before calling here; the transition has no use for it.
|
|
300
|
-
navigateTransition: (_url: string, owner: RenderOwner, types, perform, onCommit) => {
|
|
300
|
+
navigateTransition: (_url: string, owner: RenderOwner, types, perform, onCommit, onHandOff) => {
|
|
301
301
|
return navigateTransition(
|
|
302
302
|
owner,
|
|
303
303
|
async () => {
|
|
@@ -334,7 +334,8 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
334
334
|
},
|
|
335
335
|
render,
|
|
336
336
|
types,
|
|
337
|
-
onCommit
|
|
337
|
+
onCommit,
|
|
338
|
+
onHandOff
|
|
338
339
|
);
|
|
339
340
|
},
|
|
340
341
|
|
package/src/client/index.ts
CHANGED
|
@@ -76,6 +76,7 @@ export {
|
|
|
76
76
|
|
|
77
77
|
// Forms
|
|
78
78
|
export { parseFormErrors, useFormAction } from './form.tsx';
|
|
79
|
+
export { useFormField } from './use-form-field.ts';
|
|
79
80
|
export type { FormErrorsResult } from './form.tsx';
|
|
80
81
|
|
|
81
82
|
// Params. Called with no argument this returns the untyped accumulated params;
|
|
@@ -47,7 +47,9 @@
|
|
|
47
47
|
*
|
|
48
48
|
* Nothing here may reopen an action scope: no `startTransition` callback in
|
|
49
49
|
* this path may return a thenable, and no caller may invoke this from inside
|
|
50
|
-
* a React action scope
|
|
50
|
+
* a React action scope — except one that settles that action at `onHandOff`,
|
|
51
|
+
* which commits the action and the tree together on purpose (a server action
|
|
52
|
+
* redirect, TIM-1573). That is a rule about `startTransition`, not about
|
|
51
53
|
* `perform` — `perform` is async by contract and is awaited OUTSIDE any
|
|
52
54
|
* transition scope, which is exactly why it is safe.
|
|
53
55
|
*
|
|
@@ -233,6 +235,13 @@ export function createRenderOwner(kind: 'navigation' | 'revalidation'): RenderOw
|
|
|
233
235
|
* detected via `owner.outcome` (sync) and `owner.displaced` (async).
|
|
234
236
|
* No module-level state; no globalThis singleton.
|
|
235
237
|
*
|
|
238
|
+
* `onHandOff` runs synchronously right after `render` has scheduled the
|
|
239
|
+
* tree, before React can commit it. A caller inside a React action scope may
|
|
240
|
+
* settle that action here (and only here): the render is already entangled
|
|
241
|
+
* with the scope, so settling commits the action and this tree together,
|
|
242
|
+
* where settling earlier commits the action alone and settling on the commit
|
|
243
|
+
* deadlocks. The server action redirect is that caller (TIM-1573).
|
|
244
|
+
*
|
|
236
245
|
* Used for: navigate(), refresh(), popstate with fetch.
|
|
237
246
|
*/
|
|
238
247
|
export function navigateTransition(
|
|
@@ -240,7 +249,8 @@ export function navigateTransition(
|
|
|
240
249
|
perform: () => Promise<TransitionResult>,
|
|
241
250
|
render: NavigationRender,
|
|
242
251
|
types: readonly string[],
|
|
243
|
-
onCommit?: (outcome: CommitOutcome) => void
|
|
252
|
+
onCommit?: (outcome: CommitOutcome) => void,
|
|
253
|
+
onHandOff?: () => void
|
|
244
254
|
): Promise<void> {
|
|
245
255
|
const superseded = () => new DOMException('Navigation superseded', 'AbortError');
|
|
246
256
|
|
|
@@ -284,6 +294,7 @@ export function navigateTransition(
|
|
|
284
294
|
},
|
|
285
295
|
types
|
|
286
296
|
);
|
|
297
|
+
onHandOff?.();
|
|
287
298
|
// React may commit the tree before this settles — that is the point:
|
|
288
299
|
// the destination reveals as React is able to render it
|
|
289
300
|
// rather than waiting for the whole Flight stream. The await is here so
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { setHardNavigating } from './navigation-root.tsx';
|
|
13
|
-
import type { RenderOwner } from './navigation-transition.ts';
|
|
13
|
+
import type { CommitOutcome, RenderOwner } from './navigation-transition.ts';
|
|
14
14
|
import { RedirectError, ServerErrorResponse, NonRscResponse } from './rsc-fetch.ts';
|
|
15
15
|
import { recordSkew } from './router-skew.ts';
|
|
16
16
|
|
|
@@ -147,12 +147,96 @@ export interface NavigationRecoveryDeps {
|
|
|
147
147
|
currentOwner: () => RenderOwner | null;
|
|
148
148
|
leaveSpaIfOwned: SpaExits['leaveSpaIfOwned'];
|
|
149
149
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* redirect interrupted, so a redirected Back still animates as Back and a
|
|
153
|
-
* link's own `transitionTypes` survive the hop (TIM-1471).
|
|
150
|
+
* Follow a redirect to `url` — the router's own `navigate()`, carrying
|
|
151
|
+
* the interrupted navigation's {@link RedirectHop}.
|
|
154
152
|
*/
|
|
155
|
-
navigate: (url: string,
|
|
153
|
+
navigate: (url: string, hop: RedirectHop) => Promise<void>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* What a redirect hop inherits from the navigation the redirect interrupted.
|
|
158
|
+
*/
|
|
159
|
+
export interface RedirectHop {
|
|
160
|
+
/**
|
|
161
|
+
* The view transition types of the interrupted render, so a redirected Back
|
|
162
|
+
* still animates as Back and a link's own `transitionTypes` survive the
|
|
163
|
+
* hop (TIM-1471).
|
|
164
|
+
*/
|
|
165
|
+
types: readonly string[];
|
|
166
|
+
/**
|
|
167
|
+
* The interrupted navigation's history mode. A push stays a push: the
|
|
168
|
+
* address bar moves only when a destination commits, so the interrupted
|
|
169
|
+
* navigation wrote no entry, and replacing would overwrite the page being
|
|
170
|
+
* left — Back would skip it (TIM-1571). A refresh or a traversal replaces:
|
|
171
|
+
* the browser is already on the entry being redirected.
|
|
172
|
+
*/
|
|
173
|
+
replace: boolean;
|
|
174
|
+
/**
|
|
175
|
+
* The interrupted navigation's scroll mode. `<Link scroll={false}>` to a
|
|
176
|
+
* URL that redirects — a non-canonical `/x/` among them — still keeps the
|
|
177
|
+
* scroll position; the hop would otherwise default to scrolling to the top.
|
|
178
|
+
*/
|
|
179
|
+
scroll: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* The interrupted navigation's `onCommit`, as {@link HeldCommit.settle}.
|
|
182
|
+
* A followed redirect is one logical navigation: the hop's commit is the
|
|
183
|
+
* one the caller is waiting for. `<Link>` holds `isPending` until it, and
|
|
184
|
+
* a `'failed'` from the interrupted render would release it on the hop's
|
|
185
|
+
* decode, ahead of a commit React is still holding (TIM-1571). Recovery
|
|
186
|
+
* delivers it itself on every path that does not follow the redirect.
|
|
187
|
+
*/
|
|
188
|
+
onCommit?: (outcome: CommitOutcome) => void;
|
|
189
|
+
/**
|
|
190
|
+
* The interrupted navigation's `onHandOff`: a server action redirect
|
|
191
|
+
* waits for the tree that is finally handed to React, which is the
|
|
192
|
+
* redirected one (TIM-1573).
|
|
193
|
+
*/
|
|
194
|
+
onHandOff?: () => void;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A navigation's `onCommit`, split around its failure.
|
|
199
|
+
*
|
|
200
|
+
* `navigateTransition` settles `onCommit` `'failed'` as soon as `perform`
|
|
201
|
+
* throws — before the router has seen the error, so before it knows the
|
|
202
|
+
* failure is a redirect it will follow. `transition` is what the render
|
|
203
|
+
* gets: every outcome but `'failed'` reaches the caller at once, and
|
|
204
|
+
* `'failed'` is left to recovery, which either hands `settle` to the
|
|
205
|
+
* redirect hop or delivers it. `settle` runs the caller's callback at most
|
|
206
|
+
* once, so a commit or supersession already delivered wins over a later
|
|
207
|
+
* `'failed'`.
|
|
208
|
+
*/
|
|
209
|
+
export interface HeldCommit {
|
|
210
|
+
transition: ((outcome: CommitOutcome) => void) | undefined;
|
|
211
|
+
settle: ((outcome: CommitOutcome) => void) | undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function holdCommit(onCommit?: (outcome: CommitOutcome) => void): HeldCommit {
|
|
215
|
+
if (!onCommit) return { transition: undefined, settle: undefined };
|
|
216
|
+
let settled = false;
|
|
217
|
+
const settle = (outcome: CommitOutcome): void => {
|
|
218
|
+
if (settled) return;
|
|
219
|
+
settled = true;
|
|
220
|
+
onCommit(outcome);
|
|
221
|
+
};
|
|
222
|
+
return {
|
|
223
|
+
transition: (outcome) => {
|
|
224
|
+
if (outcome !== 'failed') settle(outcome);
|
|
225
|
+
},
|
|
226
|
+
settle,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The URL a redirect hop navigates to. A redirect target without a fragment
|
|
232
|
+
* keeps the one the navigation asked for, as a browser does for a 3xx
|
|
233
|
+
* (RFC 9110 §10.2.2): the server never sees the fragment, so a soft redirect
|
|
234
|
+
* — `<Link href="/docs/#install">` answered with `/docs` — cannot carry it.
|
|
235
|
+
*/
|
|
236
|
+
export function redirectHopUrl(redirectUrl: string, requestedUrl: string): string {
|
|
237
|
+
if (redirectUrl.includes('#')) return redirectUrl;
|
|
238
|
+
const hashIndex = requestedUrl.indexOf('#');
|
|
239
|
+
return hashIndex === -1 ? redirectUrl : redirectUrl + requestedUrl.slice(hashIndex);
|
|
156
240
|
}
|
|
157
241
|
|
|
158
242
|
/**
|
|
@@ -187,16 +271,21 @@ export function createNavigationRecovery({
|
|
|
187
271
|
owner: RenderOwner,
|
|
188
272
|
url: string,
|
|
189
273
|
fromUrl: string,
|
|
190
|
-
/**
|
|
191
|
-
|
|
274
|
+
/** What a redirect hop inherits from the navigation that failed. */
|
|
275
|
+
hop: RedirectHop
|
|
192
276
|
): Promise<boolean> {
|
|
193
277
|
if (error instanceof RedirectError) {
|
|
194
278
|
// Same ownership rule as leaving the SPA: a superseded navigation must
|
|
195
279
|
// not steer the document to the destination it was abandoned for.
|
|
196
|
-
if (currentOwner() !== owner)
|
|
197
|
-
|
|
280
|
+
if (currentOwner() !== owner) {
|
|
281
|
+
hop.onCommit?.('superseded');
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
await navigate(redirectHopUrl(error.redirectUrl, url), hop);
|
|
198
285
|
return true;
|
|
199
286
|
}
|
|
287
|
+
// Anything else ends this navigation here: its held `'failed'`.
|
|
288
|
+
hop.onCommit?.('failed');
|
|
200
289
|
// A server error, a non-RSC response and a version skew all end the same
|
|
201
290
|
// way: hand `url` back to the server as a document load, so it can render
|
|
202
291
|
// the outcome as HTML (design/10-error-handling.md §"Error Page Rendering
|
|
@@ -84,7 +84,9 @@ export interface NavigationPipeline {
|
|
|
84
84
|
types: readonly string[],
|
|
85
85
|
perform: () => Promise<NavigationPayload>,
|
|
86
86
|
/** See `NavigationOptions.onCommit`. */
|
|
87
|
-
onCommit?: (outcome: CommitOutcome) => void
|
|
87
|
+
onCommit?: (outcome: CommitOutcome) => void,
|
|
88
|
+
/** See `NavigationOptions.onHandOff`. */
|
|
89
|
+
onHandOff?: () => void
|
|
88
90
|
) => Promise<void>;
|
|
89
91
|
}
|
|
90
92
|
|
|
@@ -186,7 +188,8 @@ export function createNavigationPipeline({
|
|
|
186
188
|
owner: RenderOwner,
|
|
187
189
|
types: readonly string[],
|
|
188
190
|
perform: () => Promise<NavigationPayload>,
|
|
189
|
-
onCommit?: (outcome: CommitOutcome) => void
|
|
191
|
+
onCommit?: (outcome: CommitOutcome) => void,
|
|
192
|
+
onHandOff?: () => void
|
|
190
193
|
): Promise<void> {
|
|
191
194
|
// Record that THIS navigation's payload has reached React, at the moment
|
|
192
195
|
// the tree is built and handed back for `navigateTransition` to give to
|
|
@@ -260,7 +263,8 @@ export function createNavigationPipeline({
|
|
|
260
263
|
commit: commitAndForget(result.commit),
|
|
261
264
|
};
|
|
262
265
|
},
|
|
263
|
-
onCommit
|
|
266
|
+
onCommit,
|
|
267
|
+
onHandOff
|
|
264
268
|
);
|
|
265
269
|
}
|
|
266
270
|
|
|
@@ -89,6 +89,15 @@ export interface NavigationOptions {
|
|
|
89
89
|
* which of the three it was.
|
|
90
90
|
*/
|
|
91
91
|
onCommit?: (outcome: CommitOutcome) => void;
|
|
92
|
+
/**
|
|
93
|
+
* @internal Runs once, synchronously after this navigation's tree is handed
|
|
94
|
+
* to React (its transition scheduled) and before React can commit it. Not
|
|
95
|
+
* at all if the navigation is superseded or fails first; the returned
|
|
96
|
+
* promise settles then. A server action that redirects settles its
|
|
97
|
+
* `useActionState` here, so React commits the action's result and the
|
|
98
|
+
* destination together (TIM-1573).
|
|
99
|
+
*/
|
|
100
|
+
onHandOff?: () => void;
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
/**
|
|
@@ -155,7 +164,9 @@ export interface RouterDeps {
|
|
|
155
164
|
) => unknown
|
|
156
165
|
) => Promise<TransitionResult<unknown>>,
|
|
157
166
|
/** See `NavigationOptions.onCommit`; forwarded to `navigateTransition`. */
|
|
158
|
-
onCommit?: (outcome: CommitOutcome) => void
|
|
167
|
+
onCommit?: (outcome: CommitOutcome) => void,
|
|
168
|
+
/** See `NavigationOptions.onHandOff`; forwarded to `navigateTransition`. */
|
|
169
|
+
onHandOff?: () => void
|
|
159
170
|
) => Promise<void>;
|
|
160
171
|
|
|
161
172
|
/**
|
package/src/client/router.ts
CHANGED
|
@@ -18,7 +18,12 @@ import { createNavigationCommitter } from './navigation-commit.ts';
|
|
|
18
18
|
import { fetchRscPayload, NonRscResponse } from './rsc-fetch.ts';
|
|
19
19
|
import { readPayloadTree, readPublishedParams } from '../shared/payload-root.ts';
|
|
20
20
|
import { isClientStale } from './stale-client.ts';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
createScrollEffects,
|
|
23
|
+
createSpaExits,
|
|
24
|
+
createNavigationRecovery,
|
|
25
|
+
holdCommit,
|
|
26
|
+
} from './router-effects.ts';
|
|
22
27
|
import { createNavigationLifecycle } from './router-lifecycle.ts';
|
|
23
28
|
import { createNavigationPipeline, prefetchKeyFor } from './router-pipeline.ts';
|
|
24
29
|
import { recordSkew } from './router-skew.ts';
|
|
@@ -102,7 +107,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
102
107
|
currentOwner,
|
|
103
108
|
leaveSpaIfOwned,
|
|
104
109
|
// Hoisted — `navigate` is a function declaration below.
|
|
105
|
-
navigate: (url, types
|
|
110
|
+
navigate: (url, { types, replace, scroll, onCommit, onHandOff }) =>
|
|
111
|
+
navigate(url, { replace, scroll, _renderTypes: types, onCommit, onHandOff }),
|
|
106
112
|
});
|
|
107
113
|
|
|
108
114
|
async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
|
|
@@ -151,6 +157,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
151
157
|
await leaveSpaSuperseding(url, departingUrl);
|
|
152
158
|
}
|
|
153
159
|
|
|
160
|
+
// A redirect this navigation follows inherits its onCommit (RedirectHop).
|
|
161
|
+
const heldCommit = holdCommit(options.onCommit);
|
|
162
|
+
|
|
154
163
|
await runNavigation(url, async (owner) => {
|
|
155
164
|
try {
|
|
156
165
|
await renderViaTransition(
|
|
@@ -164,7 +173,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
164
173
|
signal: owner.fetchAbort.signal,
|
|
165
174
|
departingUrl,
|
|
166
175
|
}),
|
|
167
|
-
|
|
176
|
+
heldCommit.transition,
|
|
177
|
+
options.onHandOff
|
|
168
178
|
);
|
|
169
179
|
|
|
170
180
|
// Scroll-to-top on forward navigation, scroll to the #fragment target
|
|
@@ -180,7 +190,17 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
180
190
|
// load of the destination, fragment included (TIM-1234). Reloading
|
|
181
191
|
// instead would rebuild the page they were *leaving* and discard
|
|
182
192
|
// the click (TIM-1275).
|
|
183
|
-
if (
|
|
193
|
+
if (
|
|
194
|
+
await recoverFromNavigationError(error, owner, url, departingUrl, {
|
|
195
|
+
types,
|
|
196
|
+
replace,
|
|
197
|
+
scroll,
|
|
198
|
+
onCommit: heldCommit.settle,
|
|
199
|
+
onHandOff: options.onHandOff,
|
|
200
|
+
})
|
|
201
|
+
) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
184
204
|
throw error;
|
|
185
205
|
}
|
|
186
206
|
});
|
|
@@ -209,6 +229,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
209
229
|
onCommit?: (outcome: CommitOutcome) => void;
|
|
210
230
|
} = {}
|
|
211
231
|
): Promise<void> {
|
|
232
|
+
const heldCommit = holdCommit(opts.onCommit);
|
|
212
233
|
await runNavigation(
|
|
213
234
|
url,
|
|
214
235
|
async (owner) => {
|
|
@@ -234,7 +255,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
234
255
|
});
|
|
235
256
|
return { ...result, params, navState, commit };
|
|
236
257
|
},
|
|
237
|
-
|
|
258
|
+
heldCommit.transition
|
|
238
259
|
);
|
|
239
260
|
} catch (error) {
|
|
240
261
|
// Neither path is a navigate(), and neither has a caller that
|
|
@@ -248,7 +269,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
248
269
|
// browser has already traversed, and `refresh()` is by definition
|
|
249
270
|
// where it already is. So `hardNavigate()` takes its same-document
|
|
250
271
|
// branch and reloads rather than pushing an entry.
|
|
251
|
-
if (
|
|
272
|
+
if (
|
|
273
|
+
await recoverFromNavigationError(error, owner, url, url, {
|
|
274
|
+
types: [type],
|
|
275
|
+
replace: true,
|
|
276
|
+
scroll: true,
|
|
277
|
+
onCommit: heldCommit.settle,
|
|
278
|
+
})
|
|
279
|
+
)
|
|
280
|
+
return;
|
|
252
281
|
throw error;
|
|
253
282
|
}
|
|
254
283
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFormField — state for a form field that needs JavaScript (a combobox, a
|
|
3
|
+
* chip list, a row list), made to behave like a native uncontrolled input.
|
|
4
|
+
*
|
|
5
|
+
* React resets a form after its action settles, so a native field with a
|
|
6
|
+
* `defaultValue` lands on whatever default the page now renders: the action's
|
|
7
|
+
* `submittedValues`, or fresh page data after a redirect. State held in
|
|
8
|
+
* `useState` misses that reset and keeps showing the pre-submit edit. This
|
|
9
|
+
* hook shows `defaultValue` until the user edits, and drops the edit when the
|
|
10
|
+
* form resets, so it lands where the native fields do.
|
|
11
|
+
*
|
|
12
|
+
* See design/08-forms-and-actions.md §"The Form Model".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
useCallback,
|
|
17
|
+
useEffect,
|
|
18
|
+
useLayoutEffect,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
type Dispatch,
|
|
22
|
+
type RefCallback,
|
|
23
|
+
type SetStateAction,
|
|
24
|
+
} from 'react';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* State for a field that follows its form's reset.
|
|
28
|
+
*
|
|
29
|
+
* Returns `[value, setValue, ref]`. Attach `ref` to any element inside the
|
|
30
|
+
* `<form>`, and render the value into the form data yourself (usually a
|
|
31
|
+
* hidden input). `value` is `defaultValue` until `setValue` is called, and is
|
|
32
|
+
* `defaultValue` again after the form resets, unless the form cancels its
|
|
33
|
+
* `reset` event. An edit dispatches a bubbling `change` event from the `ref`
|
|
34
|
+
* element after it commits, so a native `change` listener on the form sees it
|
|
35
|
+
* as it sees a native input's (React's synthetic `onChange` reports only
|
|
36
|
+
* inputs, selects and textareas). A reset and a new default fire none, as
|
|
37
|
+
* they fire none for a native input.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* // draft: the form's defaults, read from result?.submittedValues ?? pageData
|
|
42
|
+
* const [tags, setTags, ref] = useFormField(draft.tags);
|
|
43
|
+
* <div ref={ref}>
|
|
44
|
+
* <input type="hidden" name="tags" value={tags.join(',')} />
|
|
45
|
+
* <TagPicker value={tags} onChange={setTags} />
|
|
46
|
+
* </div>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function useFormField<T>(
|
|
50
|
+
defaultValue: T
|
|
51
|
+
): [value: T, setValue: Dispatch<SetStateAction<T>>, ref: RefCallback<HTMLElement>] {
|
|
52
|
+
// `null` means "not edited": the field shows its default. A box, so an edit
|
|
53
|
+
// back to a value equal to the default still counts as an edit.
|
|
54
|
+
const [edit, setEdit] = useState<{ value: T } | null>(null);
|
|
55
|
+
const value = edit ? edit.value : defaultValue;
|
|
56
|
+
|
|
57
|
+
// The latest default, for an updater called from an old closure. Written
|
|
58
|
+
// after commit, never during render.
|
|
59
|
+
const defaultRef = useRef(defaultValue);
|
|
60
|
+
useLayoutEffect(() => {
|
|
61
|
+
defaultRef.current = defaultValue;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Resets the form has fired whose outcome this field has not applied yet.
|
|
65
|
+
// A reset's outcome is known only once its dispatch is over: our listener
|
|
66
|
+
// runs on the form before React's delegated `onReset`, which may cancel
|
|
67
|
+
// it. `takeResets` applies every reset whose dispatch has ended, in order:
|
|
68
|
+
// if any of them was not cancelled, the field was reset. Both the deferred
|
|
69
|
+
// clear and `setValue` call it, so writes follow a native input's order:
|
|
70
|
+
// - after `form.reset()` returns, the reset has happened and a write lands
|
|
71
|
+
// on top of it (the write takes the reset, and the clear finds nothing);
|
|
72
|
+
// - during the reset's dispatch (an `onReset` handler), the reset is still
|
|
73
|
+
// pending: the write applies to the current value, and the clear then
|
|
74
|
+
// erases it unless the reset is cancelled, as a reset overwrites a
|
|
75
|
+
// native input written in its handler.
|
|
76
|
+
// A list, not one slot: `reset(); reset()` where only the second is
|
|
77
|
+
// cancelled still resets, as it does the native fields.
|
|
78
|
+
const pendingResets = useRef<Event[]>([]);
|
|
79
|
+
const takeResets = useCallback((): boolean => {
|
|
80
|
+
// eventPhase is NONE once dispatch is over.
|
|
81
|
+
const over = pendingResets.current.filter((e) => e.eventPhase === Event.NONE);
|
|
82
|
+
if (over.length === 0) return false;
|
|
83
|
+
pendingResets.current = pendingResets.current.filter((e) => !over.includes(e));
|
|
84
|
+
return over.some((e) => !e.defaultPrevented);
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const setValue = useCallback<Dispatch<SetStateAction<T>>>(
|
|
88
|
+
(next) => {
|
|
89
|
+
const wasReset = takeResets();
|
|
90
|
+
setEdit((latest) => {
|
|
91
|
+
const current = wasReset ? null : latest;
|
|
92
|
+
const base = current ? current.value : defaultRef.current;
|
|
93
|
+
const value = next instanceof Function ? next(base) : next;
|
|
94
|
+
// Unchanged: keep the box, so nothing re-renders and no `change`
|
|
95
|
+
// fires, as a native input fires none for a value it already has.
|
|
96
|
+
return Object.is(value, base) ? current : { value };
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
[takeResets]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const element = useRef<HTMLElement | null>(null);
|
|
103
|
+
const ref = useCallback<RefCallback<HTMLElement>>(
|
|
104
|
+
(el) => {
|
|
105
|
+
element.current = el;
|
|
106
|
+
const form = el?.closest('form');
|
|
107
|
+
if (!form) return;
|
|
108
|
+
const onReset = (event: Event) => {
|
|
109
|
+
pendingResets.current.push(event);
|
|
110
|
+
// Dispatch is over by the time a microtask runs.
|
|
111
|
+
queueMicrotask(() => {
|
|
112
|
+
if (takeResets()) setEdit(null);
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
form.addEventListener('reset', onReset);
|
|
116
|
+
return () => {
|
|
117
|
+
element.current = null;
|
|
118
|
+
pendingResets.current = [];
|
|
119
|
+
form.removeEventListener('reset', onReset);
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
[takeResets]
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Every `setValue` stores a new box, and nothing else does except a reset
|
|
126
|
+
// (which stores null), so a new non-null box is exactly "the user edited".
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (edit) element.current?.dispatchEvent(new Event('change', { bubbles: true }));
|
|
129
|
+
}, [edit]);
|
|
130
|
+
|
|
131
|
+
return [value, setValue, ref];
|
|
132
|
+
}
|
package/src/config-types.ts
CHANGED
|
@@ -56,7 +56,8 @@ export interface TimberUserConfig {
|
|
|
56
56
|
forms?: {
|
|
57
57
|
/**
|
|
58
58
|
* Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the
|
|
59
|
-
* `submittedValues` echoed back
|
|
59
|
+
* `submittedValues` echoed back when an action fails (a validation
|
|
60
|
+
* failure, or a `serverError` from a form submission).
|
|
60
61
|
*
|
|
61
62
|
* Applied to both the with-JS (`createActionClient`) and no-JS (form POST
|
|
62
63
|
* re-render) paths. Safe-by-default: the built-in deny-list is active
|