@timber-js/app 0.2.0-alpha.211 → 0.2.0-alpha.213
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-CEootpB1.js} +49 -12
- package/dist/_chunks/actions-CEootpB1.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/{error-boundary-DsNScGRM.js → error-boundary-9g_Lb2na.js} +3 -3
- package/dist/_chunks/{error-boundary-DsNScGRM.js.map → error-boundary-9g_Lb2na.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/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/form.d.ts +17 -64
- package/dist/client/form.d.ts.map +1 -1
- package/dist/client/index.d.ts +3 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +111 -51
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +33 -32
- 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/navigation-transition.d.ts +11 -2
- package/dist/client/navigation-transition.d.ts.map +1 -1
- package/dist/client/router-effects.d.ts +7 -3
- package/dist/client/router-effects.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 +3 -2
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +24 -44
- 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 +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 +20 -7
- 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/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.d.ts +0 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +41 -60
- 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/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/rsc-stream.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 +10 -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-form-state.d.ts +30 -0
- package/dist/server/ssr-form-state.d.ts.map +1 -0
- package/dist/server/ssr-render.d.ts +3 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- 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 +31 -3
- package/docs/api/34-api-config.mdx +1 -1
- package/docs/learn/08-forms-and-actions.mdx +116 -36
- package/package.json +1 -1
- package/src/client/browser-entry/action-dispatch.ts +104 -24
- 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 +10 -1
- package/src/client/browser-entry/index.ts +25 -7
- package/src/client/browser-entry/router-init.ts +7 -20
- package/src/client/form.tsx +33 -98
- package/src/client/index.ts +3 -2
- package/src/client/navigation-api.ts +47 -173
- package/src/client/navigation-transition.ts +15 -4
- package/src/client/router-effects.ts +8 -4
- package/src/client/router-lifecycle.ts +40 -20
- package/src/client/router-pipeline.ts +10 -12
- package/src/client/router-types.ts +24 -48
- package/src/client/router.ts +49 -56
- package/src/client/use-form-field.ts +132 -0
- package/src/config-types.ts +2 -1
- 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 +77 -64
- 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/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/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 +40 -34
- package/src/server/rsc-entry/error-renderer.ts +2 -1
- package/src/server/rsc-entry/index.ts +1 -0
- package/src/server/rsc-entry/render-route.ts +16 -0
- package/src/server/rsc-entry/rsc-stream.ts +20 -11
- package/src/server/rsc-entry/ssr-renderer.ts +11 -0
- package/src/server/ssr-bridge-types.ts +10 -0
- package/src/server/ssr-entry.ts +16 -2
- package/src/server/ssr-form-state.ts +58 -0
- package/src/server/ssr-render.ts +5 -0
- package/src/shared/form-state-flight.ts +74 -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
|
@@ -51,11 +51,13 @@ The `.schema()` method accepts any Standard Schema-compatible validator — Zod,
|
|
|
51
51
|
```tsx title="app/todos/todo-form.tsx"
|
|
52
52
|
'use client';
|
|
53
53
|
|
|
54
|
-
import { useActionState } from '
|
|
54
|
+
import { useActionState } from 'react';
|
|
55
|
+
import { parseFormErrors } from '@timber-js/app/client';
|
|
55
56
|
import { createTodo } from './actions';
|
|
56
57
|
|
|
57
58
|
export default function TodoForm() {
|
|
58
|
-
const [state, action, pending
|
|
59
|
+
const [state, action, pending] = useActionState(createTodo, null);
|
|
60
|
+
const errors = parseFormErrors(state);
|
|
59
61
|
|
|
60
62
|
return (
|
|
61
63
|
<form action={action}>
|
|
@@ -76,14 +78,15 @@ export default function TodoForm() {
|
|
|
76
78
|
}
|
|
77
79
|
```
|
|
78
80
|
|
|
79
|
-
Without JS, this submits as a standard form POST. With JS, `useActionState` handles pending state and error display.
|
|
81
|
+
`useActionState` is React's own hook. An action built with `createActionClient` passes to it directly, and `state` is typed from the action's result. Without JS, this submits as a standard form POST. With JS, `useActionState` handles pending state and error display.
|
|
80
82
|
|
|
81
83
|
### Form Errors
|
|
82
84
|
|
|
83
|
-
`
|
|
85
|
+
`parseFormErrors` from `@timber-js/app/client` reads the errors out of the result:
|
|
84
86
|
|
|
85
87
|
```tsx
|
|
86
|
-
const [state, action, pending
|
|
88
|
+
const [state, action, pending] = useActionState(myAction, null);
|
|
89
|
+
const errors = parseFormErrors(state);
|
|
87
90
|
|
|
88
91
|
errors.fieldErrors; // Record<string, string[]>
|
|
89
92
|
errors.formErrors; // string[] (form-level errors)
|
|
@@ -92,55 +95,132 @@ errors.hasErrors; // boolean
|
|
|
92
95
|
errors.getFieldError('title'); // string | null (first error for field)
|
|
93
96
|
```
|
|
94
97
|
|
|
95
|
-
##
|
|
98
|
+
## Keeping What the User Typed
|
|
96
99
|
|
|
97
|
-
|
|
98
|
-
| ------------------------- | ------------------------------------------------ |
|
|
99
|
-
| `redirect('/path')` | User should land somewhere new |
|
|
100
|
-
| `revalidatePath('/path')` | Current page needs fresh data |
|
|
101
|
-
| `useOptimistic` | Instant UI update, action confirms in background |
|
|
100
|
+
React resets a form after every action: each field goes back to its `defaultValue`. Build forms on that reset, and they keep the user's input after a failure and show the saved values after a save, with or without JavaScript:
|
|
102
101
|
|
|
103
|
-
|
|
102
|
+
1. Leave fields **uncontrolled**: a `name` and a `defaultValue`, not `value` + `onChange`.
|
|
103
|
+
2. Take defaults from the result, then from the page: `result?.submittedValues ?? pageData`.
|
|
104
|
+
3. On success, `redirect()` (or `revalidatePath()`), so the page renders the saved data.
|
|
105
|
+
|
|
106
|
+
```ts title="app/events/[id]/actions.ts"
|
|
107
|
+
'use server';
|
|
104
108
|
|
|
105
|
-
|
|
109
|
+
import { z } from 'zod/v4';
|
|
110
|
+
import { coerce, redirect } from '@timber-js/app/server';
|
|
111
|
+
import { action } from '@/lib/action';
|
|
106
112
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
declare const db: { events: { update(data: unknown): Promise<void> } };
|
|
114
|
+
|
|
115
|
+
export const saveEvent = action
|
|
116
|
+
.schema(
|
|
117
|
+
z.object({
|
|
118
|
+
id: z.string(),
|
|
119
|
+
title: z.string().min(1, 'Title is required'),
|
|
120
|
+
category: z.string(),
|
|
121
|
+
ticketed: z.preprocess(coerce.checkbox, z.boolean()),
|
|
122
|
+
// sets.0.name, sets.1.name… arrive as an array
|
|
123
|
+
sets: z.array(z.object({ name: z.string().min(1) })).default([]), // no rows: no key
|
|
124
|
+
})
|
|
125
|
+
)
|
|
126
|
+
.action(async ({ input }) => {
|
|
127
|
+
await db.events.update(input);
|
|
128
|
+
redirect(`/events/${input.id}`);
|
|
129
|
+
});
|
|
130
|
+
```
|
|
110
131
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
132
|
+
`submittedValues` is the raw form: strings, lists as arrays, an unchecked box absent. Page data is typed. One small, lenient function reads either into what the fields render:
|
|
133
|
+
|
|
134
|
+
```ts title="app/events/[id]/draft.ts"
|
|
135
|
+
export function toDraft(values: Record<string, unknown>) {
|
|
136
|
+
const sets: { name?: unknown }[] = Array.isArray(values.sets) ? values.sets : [];
|
|
137
|
+
return {
|
|
138
|
+
title: String(values.title ?? ''),
|
|
139
|
+
category: String(values.category ?? ''),
|
|
140
|
+
// Submitted: "on", or absent when unchecked. Saved: a boolean.
|
|
141
|
+
ticketed: values.ticketed === true || values.ticketed === 'on',
|
|
142
|
+
sets: sets.map((set) => ({ name: String(set?.name ?? '') })),
|
|
143
|
+
};
|
|
114
144
|
}
|
|
115
145
|
```
|
|
116
146
|
|
|
117
|
-
```tsx title="app/
|
|
147
|
+
```tsx title="app/events/[id]/event-form.tsx"
|
|
118
148
|
'use client';
|
|
119
149
|
|
|
120
|
-
import { useActionState } from '
|
|
121
|
-
import {
|
|
122
|
-
import
|
|
150
|
+
import { useActionState } from 'react';
|
|
151
|
+
import { parseFormErrors } from '@timber-js/app/client';
|
|
152
|
+
import { saveEvent } from './actions';
|
|
153
|
+
import { toDraft } from './draft';
|
|
123
154
|
|
|
124
|
-
|
|
125
|
-
const [state, action, pending, errors] = useActionState(createTodo, null);
|
|
155
|
+
type Event = { id: string; title: string; category: string; ticketed: boolean; sets: { name: string }[] };
|
|
126
156
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
157
|
+
export function EventForm({ event }: { event: Event }) {
|
|
158
|
+
const [result, action, pending] = useActionState(saveEvent, null);
|
|
159
|
+
const errors = parseFormErrors(result);
|
|
160
|
+
// After a failure: what the user submitted. Otherwise: the saved event.
|
|
161
|
+
const d = toDraft(result?.submittedValues ?? event);
|
|
131
162
|
|
|
132
163
|
return (
|
|
133
164
|
<form action={action}>
|
|
134
|
-
<input name="
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
165
|
+
<input type="hidden" name="id" value={event.id} />
|
|
166
|
+
<input name="title" defaultValue={d.title} />
|
|
167
|
+
{errors.getFieldError('title')}
|
|
168
|
+
|
|
169
|
+
{/* React applies a select's defaultValue only on mount: key it on the default */}
|
|
170
|
+
<select key={d.category} name="category" defaultValue={d.category}>
|
|
171
|
+
<option value="meetup">Meetup</option>
|
|
172
|
+
<option value="social">Social</option>
|
|
173
|
+
</select>
|
|
174
|
+
|
|
175
|
+
<input type="checkbox" name="ticketed" defaultChecked={d.ticketed} />
|
|
176
|
+
|
|
177
|
+
{d.sets.map((set, i) => (
|
|
178
|
+
<input key={i} name={`sets.${i}.name`} defaultValue={set.name} />
|
|
179
|
+
))}
|
|
180
|
+
|
|
181
|
+
{errors.serverError && <p>Could not save. Please try again.</p>}
|
|
182
|
+
<button disabled={pending}>Save</button>
|
|
139
183
|
</form>
|
|
140
184
|
);
|
|
141
185
|
}
|
|
142
186
|
```
|
|
143
187
|
|
|
144
|
-
|
|
188
|
+
When a form submission fails — a validation error, an `ActionError`, or an unexpected error — the result carries `submittedValues`, so the user's input survives any of them.
|
|
189
|
+
|
|
190
|
+
Three things break the model:
|
|
191
|
+
|
|
192
|
+
- **Controlled fields.** A field whose value lives in state keeps showing that state after the reset has put the DOM back to its default, and the next submit sends what the DOM holds, not what the user sees. If a form must be controlled, cancel the reset: `<form action={action} onReset={(e) => e.preventDefault()}>`.
|
|
193
|
+
- **An unkeyed `<select>`.** React ignores a changed `defaultValue` on a mounted `<select>`. Key it on its default, as above. Inputs, checkboxes and textareas don't need this.
|
|
194
|
+
- **`useState` for a field that needs JavaScript.** A combobox or a chip list can't be a native input. Hold its state in `useFormField` instead, which follows the reset like a native input:
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
import { useFormField } from '@timber-js/app/client';
|
|
198
|
+
|
|
199
|
+
function TagsField({ defaultValue }: { defaultValue: string[] }) {
|
|
200
|
+
const [tags, setTags, ref] = useFormField(defaultValue);
|
|
201
|
+
return (
|
|
202
|
+
<div ref={ref}>
|
|
203
|
+
<input type="hidden" name="tags" value={tags.join(',')} />
|
|
204
|
+
<TagPicker value={tags} onChange={setTags} />
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## After Mutation
|
|
211
|
+
|
|
212
|
+
| Pattern | When to use |
|
|
213
|
+
| ------------------------- | ------------------------------------------------ |
|
|
214
|
+
| `redirect('/path')` | User should land somewhere new |
|
|
215
|
+
| `revalidatePath('/path')` | Current page needs fresh data |
|
|
216
|
+
| `useOptimistic` | Instant UI update, action confirms in background |
|
|
217
|
+
|
|
218
|
+
## Forms Without JavaScript
|
|
219
|
+
|
|
220
|
+
The same form works with JavaScript disabled, with no extra code. When a no-JS submission's action returns instead of redirecting, timber renders the page again and React hands the result to the `useActionState` hook that submitted it. `result` holds the result on both paths, so errors and the fields' defaults render the same way, and the server writes the user's input back into the HTML.
|
|
221
|
+
|
|
222
|
+
The result goes only to the hook that submitted, so two forms on one page never see each other's results. It has the same types on both paths: a `Date` your action returns is a `Date` in `state` whether or not JavaScript ran. Sensitive fields (password, apiKey, cvv, etc.) are automatically stripped from `submittedValues`.
|
|
223
|
+
|
|
224
|
+
A form that doesn't use `useActionState` gets no result back, just as with JavaScript, where React discards a plain `<form action>`'s return value. Such an action should `redirect()` to a page that shows the new state (post-redirect-get). An action that throws renders the nearest `error.tsx`: with JavaScript the action rejects to the nearest error boundary, and without it the page answers from the same `error.tsx` with a 500; return errors from `createActionClient` actions instead.
|
|
145
225
|
|
|
146
|
-
For advanced topics — coercion helpers, `parseFormData`,
|
|
226
|
+
For advanced topics — coercion helpers, `parseFormData`, indexed lists, and sensitive field stripping configuration — see [Advanced Forms](/docs/advanced-forms).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.213",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -23,21 +23,7 @@ import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../r
|
|
|
23
23
|
import { markClientStale } from '../stale-client.ts';
|
|
24
24
|
import { RSC_CONTENT_TYPE } from '../../shared/rsc-media-type.ts';
|
|
25
25
|
import { CSRF_REJECT_HEADER, CSRF_REJECT_VALUE } from '../../shared/csrf-reject.ts';
|
|
26
|
-
import { createActionQueue } from './action-queue.ts';
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Wait for a navigation or refresh to both settle (promise resolved) AND
|
|
30
|
-
* commit (pushState / the navigation's commit thunk ran). The queue must
|
|
31
|
-
* not release the next action until the URL is correct in the address bar
|
|
32
|
-
* — same rule `<Link>` uses for `isPending` (design/19 §Per-Link Pending
|
|
33
|
-
* State). `onCommit` fires exactly once: on commit, supersession, or
|
|
34
|
-
* failure, so nothing awaiting this can hang (codex on #1125).
|
|
35
|
-
*/
|
|
36
|
-
function settledAndCommitted(start: (onCommit: () => void) => Promise<void>): Promise<void> {
|
|
37
|
-
let commitResolve!: () => void;
|
|
38
|
-
const committed = new Promise<void>((r) => (commitResolve = r));
|
|
39
|
-
return Promise.all([start(commitResolve).catch(() => {}), committed]).then(() => {});
|
|
40
|
-
}
|
|
26
|
+
import { createActionQueue, DEFAULT_HOLD_TIMEOUT_MS } from './action-queue.ts';
|
|
41
27
|
|
|
42
28
|
export function setupServerActions(): void {
|
|
43
29
|
const queue = createActionQueue({
|
|
@@ -60,8 +46,30 @@ export function setupServerActions(): void {
|
|
|
60
46
|
},
|
|
61
47
|
});
|
|
62
48
|
|
|
49
|
+
// The page that submitted a redirecting action stays mounted until the
|
|
50
|
+
// destination commits. Its own useActionState stays pending until the
|
|
51
|
+
// hand-off (below), but other forms and effects on it can still dispatch,
|
|
52
|
+
// and a second submit would run the mutation again (TIM-1568). So the
|
|
53
|
+
// departing page may not dispatch: `departing` is set from the redirect
|
|
54
|
+
// response until the router is next idle, and a call made
|
|
55
|
+
// meanwhile is never sent. It resolves at once with `undefined` — what the
|
|
56
|
+
// redirecting action itself returned. It must settle: React entangles every
|
|
57
|
+
// transition started while an action is pending, so one left pending would
|
|
58
|
+
// hold the redirect's own commit forever. `pageGen` names
|
|
59
|
+
// the page a call was made on, so one queued behind the redirecting
|
|
60
|
+
// action is dropped too. The check is here rather than in the DOM
|
|
61
|
+
// (`inert`) because it covers every way a call is made: a click inside a
|
|
62
|
+
// modal dialog or a shadow root, `requestSubmit()` from a keyboard
|
|
63
|
+
// shortcut, an effect.
|
|
64
|
+
let departing = false;
|
|
65
|
+
let pageGen = 0;
|
|
66
|
+
|
|
63
67
|
setServerCallback((id: string, args: unknown[]) => {
|
|
68
|
+
if (departing) return Promise.resolve(undefined);
|
|
69
|
+
const calledOn = pageGen;
|
|
64
70
|
return queue.run(async ({ hold }) => {
|
|
71
|
+
if (calledOn !== pageGen) return undefined;
|
|
72
|
+
|
|
65
73
|
// encodeReply inside the queue — preserves call order when two
|
|
66
74
|
// calls' args serialize in different numbers of microtasks.
|
|
67
75
|
const body = await encodeReply(args);
|
|
@@ -75,6 +83,9 @@ export function setupServerActions(): void {
|
|
|
75
83
|
let hasRevalidation = false;
|
|
76
84
|
let hasRedirect = false;
|
|
77
85
|
let invalidated = false;
|
|
86
|
+
// Set once the server answered the action itself — past the version
|
|
87
|
+
// skew and CSRF rejections, which run nothing.
|
|
88
|
+
let answered = false;
|
|
78
89
|
|
|
79
90
|
const actionHeaders: Record<string, string> = {
|
|
80
91
|
'Accept': RSC_CONTENT_TYPE,
|
|
@@ -104,10 +115,26 @@ export function setupServerActions(): void {
|
|
|
104
115
|
hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
|
|
105
116
|
hasRedirect = res.headers.get('X-Timber-Redirect') != null;
|
|
106
117
|
invalidated = res.headers.get('X-Timber-Invalidated') === '1';
|
|
118
|
+
answered = true;
|
|
107
119
|
return res;
|
|
108
120
|
});
|
|
109
121
|
|
|
110
|
-
|
|
122
|
+
let decoded: unknown;
|
|
123
|
+
try {
|
|
124
|
+
decoded = await createFromFetch(response);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
// A raw server function that throws arrives as a rejected root
|
|
127
|
+
// (TIM-1570). It may have mutated before it threw, so no payload
|
|
128
|
+
// cached before it may be reused, nor the mounted shared layouts on
|
|
129
|
+
// the next navigation. No refresh: the rejection renders the nearest
|
|
130
|
+
// error boundary, and a refresh would hand that boundary new
|
|
131
|
+
// children, which resets it and hides the error (error-boundary.tsx).
|
|
132
|
+
if (answered && router) {
|
|
133
|
+
router.suppressSegmentReuse();
|
|
134
|
+
router.evictStaleCaches();
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
111
138
|
|
|
112
139
|
// The mutation happened server-side, and the next thing on screen is
|
|
113
140
|
// not a full render of the current page: revalidatePath named only
|
|
@@ -136,14 +163,32 @@ export function setupServerActions(): void {
|
|
|
136
163
|
}
|
|
137
164
|
|
|
138
165
|
// Redirects apply unconditionally — the server mutated and told
|
|
139
|
-
// the client where to go.
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
166
|
+
// the client where to go. The action settles (returning undefined)
|
|
167
|
+
// once the destination's tree has been handed to React, not before
|
|
168
|
+
// and not after:
|
|
169
|
+
//
|
|
170
|
+
// - Not after its commit: React entangles the commit with the action
|
|
171
|
+
// scope, so the commit would wait on the action while the action
|
|
172
|
+
// waited on the commit.
|
|
173
|
+
// - Not before the hand-off: React would commit the action alone —
|
|
174
|
+
// useActionState's result, and React's reset of the submitting form
|
|
175
|
+
// onto the defaults the *departing* page renders — and the form
|
|
176
|
+
// would show the pre-save values until the destination committed
|
|
177
|
+
// (TIM-1573).
|
|
178
|
+
//
|
|
179
|
+
// At the hand-off the destination's render is already entangled with
|
|
180
|
+
// the action, so settling commits both together: the form resets onto
|
|
181
|
+
// the destination's defaults. The wait always ends: the navigation
|
|
182
|
+
// hands off, or fails, or a navigation the user starts supersedes it
|
|
183
|
+
// (its promise settles either way), or DEFAULT_HOLD_TIMEOUT_MS
|
|
184
|
+
// passes. Every transition waits while an action is pending, so this
|
|
185
|
+
// must not hang on a stalled fetch.
|
|
186
|
+
//
|
|
187
|
+
// Set before returning, so React never sees the action settle while
|
|
188
|
+
// the departing page can still dispatch (see `departing`).
|
|
146
189
|
if (hasRedirect) {
|
|
190
|
+
departing = true;
|
|
191
|
+
pageGen += 1;
|
|
147
192
|
const wrapper = decoded as { _redirect: string; _status: number };
|
|
148
193
|
const isExternal =
|
|
149
194
|
wrapper._redirect.startsWith('https://') || wrapper._redirect.startsWith('http://');
|
|
@@ -156,7 +201,42 @@ export function setupServerActions(): void {
|
|
|
156
201
|
}
|
|
157
202
|
try {
|
|
158
203
|
const r = getRouter();
|
|
159
|
-
|
|
204
|
+
// The redirect's own navigation takes the next sequence number.
|
|
205
|
+
// A tree handed to React before it — a refresh of this very page
|
|
206
|
+
// still suspended — can commit afterwards, and must not release
|
|
207
|
+
// the latch: it is the departing page again (codex on #1211).
|
|
208
|
+
const redirectSeq = r.epoch().seq + 1;
|
|
209
|
+
let handOff!: () => void;
|
|
210
|
+
const handedOff = new Promise<void>((resolve) => (handOff = resolve));
|
|
211
|
+
r.navigate(wrapper._redirect, { onHandOff: handOff }).then(handOff, handOff);
|
|
212
|
+
// Released once the departing page can no longer be on screen:
|
|
213
|
+
// when any navigation's tree commits (a streamed destination
|
|
214
|
+
// commits its shell before its stream finishes, and is live from
|
|
215
|
+
// then), or when the router is idle with nothing committed (every
|
|
216
|
+
// navigation was abandoned; the user stays and may act again).
|
|
217
|
+
// Not on the redirect navigation's own outcome: a navigation the
|
|
218
|
+
// user starts meanwhile supersedes it at once, but the departing
|
|
219
|
+
// page stays until that successor commits (codex on #1211).
|
|
220
|
+
const stops: (() => void)[] = [];
|
|
221
|
+
const arrive = (): void => {
|
|
222
|
+
departing = false;
|
|
223
|
+
for (const stop of stops) stop();
|
|
224
|
+
};
|
|
225
|
+
stops.push(
|
|
226
|
+
r.onTreeCommit((seq) => {
|
|
227
|
+
if (seq >= redirectSeq) arrive();
|
|
228
|
+
}),
|
|
229
|
+
r.onIdle(arrive)
|
|
230
|
+
);
|
|
231
|
+
// A navigation that failed before taking the router never makes
|
|
232
|
+
// it busy, so no idle transition would come.
|
|
233
|
+
if (r.epoch().idle) arrive();
|
|
234
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
235
|
+
await Promise.race([
|
|
236
|
+
handedOff,
|
|
237
|
+
new Promise<void>((resolve) => (timer = setTimeout(resolve, DEFAULT_HOLD_TIMEOUT_MS))),
|
|
238
|
+
]);
|
|
239
|
+
clearTimeout(timer);
|
|
160
240
|
} catch (e) {
|
|
161
241
|
console.debug(
|
|
162
242
|
'[timber] action redirect: router not ready, using full navigation',
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form state a page answering a no-JS action embeds for hydration
|
|
3
|
+
* (`self.__timber_form_state`, server/form-state-flight.ts).
|
|
4
|
+
*
|
|
5
|
+
* `hydrateRoot` needs the value Fizz rendered with, or the `useActionState`
|
|
6
|
+
* hook that submitted hydrates back to its initial state. The embed is the
|
|
7
|
+
* form state's Flight bytes, the same bytes SSR decoded for Fizz, so the
|
|
8
|
+
* browser decodes them with its own Flight client before it hydrates. Only
|
|
9
|
+
* this page waits for that; a page without the embed hydrates at once
|
|
10
|
+
* (TIM-1297: hydration does not wait for the payload root).
|
|
11
|
+
*
|
|
12
|
+
* See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
16
|
+
import { createFromReadableStream } from '../../rsc-runtime/browser.ts';
|
|
17
|
+
import { decodeFormState, formStateFromBase64 } from '../../shared/form-state-flight.ts';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every byte is already in the document, so decoding takes microtasks. The
|
|
21
|
+
* bound only keeps a Flight client bug from holding hydration forever.
|
|
22
|
+
*/
|
|
23
|
+
const DECODE_TIMEOUT_MS = 5_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Take the embedded form state, and remove it. `null` when the page has
|
|
27
|
+
* none, which is every page but one answering a no-JS action. Otherwise a
|
|
28
|
+
* promise of the decoded state, or of `null` if it could not be decoded: the
|
|
29
|
+
* page then hydrates as though it had none, which resets that one hook.
|
|
30
|
+
*/
|
|
31
|
+
export function takeEmbeddedFormState(): Promise<ReactFormState | null> | null {
|
|
32
|
+
const embedded: unknown = Reflect.get(self, '__timber_form_state');
|
|
33
|
+
Reflect.deleteProperty(self, '__timber_form_state');
|
|
34
|
+
if (typeof embedded !== 'string') return null;
|
|
35
|
+
// Inside the chain, so malformed base64 falls back like a failed decode.
|
|
36
|
+
return Promise.resolve(embedded)
|
|
37
|
+
.then((text) =>
|
|
38
|
+
decodeFormState(
|
|
39
|
+
formStateFromBase64(text),
|
|
40
|
+
(stream) => createFromReadableStream(stream),
|
|
41
|
+
DECODE_TIMEOUT_MS
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
.catch((error: unknown) => {
|
|
45
|
+
console.warn('[timber] the form state of a no-JS action did not decode:', error);
|
|
46
|
+
return null;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* See design/19-client-navigation.md §"NavigationContext"
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
25
26
|
import { isPageUnloading } from '../unload-guard.ts';
|
|
26
27
|
import type { ReactRootHost } from '../react-root.ts';
|
|
27
28
|
import { readPublishedParams } from '../../shared/payload-root.ts';
|
|
@@ -75,6 +76,13 @@ interface HydrateOptions {
|
|
|
75
76
|
reactRoot: ReactRootHost;
|
|
76
77
|
/** Makes the page current and builds its tree — see `RouterInitResult.hydrate`. */
|
|
77
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;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
/**
|
|
@@ -105,7 +113,7 @@ function takeEmbeddedSegmentInfo(): SegmentInfo[] | null {
|
|
|
105
113
|
* first navigation or revalidation, creates it with the tree it renders
|
|
106
114
|
* (`createReactRoot`, TIM-600 / TIM-580).
|
|
107
115
|
*/
|
|
108
|
-
export function hydrateApp({ rscResult, reactRoot, hydrate }: HydrateOptions): void {
|
|
116
|
+
export function hydrateApp({ rscResult, reactRoot, hydrate, formState }: HydrateOptions): void {
|
|
109
117
|
// The chain is the router's own `renderTree`, not a copy: an element type
|
|
110
118
|
// that is present here and absent on the first navigation (or the
|
|
111
119
|
// reverse) changes the type at that position, and React remounts
|
|
@@ -126,6 +134,7 @@ export function hydrateApp({ rscResult, reactRoot, hydrate }: HydrateOptions): v
|
|
|
126
134
|
// construction when the tree it builds renders.
|
|
127
135
|
hydrate(page, (element) =>
|
|
128
136
|
reactRoot.hydrate(element, {
|
|
137
|
+
formState,
|
|
129
138
|
// Suppress recoverable hydration errors from deny/error signals
|
|
130
139
|
// inside Suspense boundaries. The server already handled these
|
|
131
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
|
+
}
|
|
@@ -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);
|
|
@@ -299,7 +297,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
299
297
|
//
|
|
300
298
|
// `_url` names the pending URL the router already published to its own
|
|
301
299
|
// pending store before calling here; the transition has no use for it.
|
|
302
|
-
navigateTransition: (_url: string, owner: RenderOwner, types, perform, onCommit) => {
|
|
300
|
+
navigateTransition: (_url: string, owner: RenderOwner, types, perform, onCommit, onHandOff) => {
|
|
303
301
|
return navigateTransition(
|
|
304
302
|
owner,
|
|
305
303
|
async () => {
|
|
@@ -336,7 +334,8 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
336
334
|
},
|
|
337
335
|
render,
|
|
338
336
|
types,
|
|
339
|
-
onCommit
|
|
337
|
+
onCommit,
|
|
338
|
+
onHandOff
|
|
340
339
|
);
|
|
341
340
|
},
|
|
342
341
|
|
|
@@ -361,15 +360,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
361
360
|
let navApiController: NavigationApiController | null = null;
|
|
362
361
|
if (useNavApi) {
|
|
363
362
|
navApiController = setupNavigationApi({
|
|
364
|
-
onExternalNavigate:
|
|
365
|
-
await router.navigate(url, {
|
|
366
|
-
replace,
|
|
367
|
-
scroll,
|
|
368
|
-
_signal: signal,
|
|
369
|
-
_skipHistory: true,
|
|
370
|
-
_departingUrl: departingUrl,
|
|
371
|
-
});
|
|
372
|
-
},
|
|
363
|
+
onExternalNavigate: (url, { replace }) => router.navigate(url, { replace }),
|
|
373
364
|
onTraverse: async (url, scrollY, signal, direction) => {
|
|
374
365
|
// Back/forward — delegate to the router's popstate handler.
|
|
375
366
|
await router.handlePopState(url, scrollY, signal, direction);
|
|
@@ -380,12 +371,8 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
380
371
|
},
|
|
381
372
|
});
|
|
382
373
|
|
|
383
|
-
//
|
|
384
|
-
// This must be done after setupNavigationApi returns the controller.
|
|
385
|
-
deps.setRouterNavigating = (v) => navApiController!.setRouterNavigating(v);
|
|
374
|
+
// Wired after setupNavigationApi returns the controller.
|
|
386
375
|
deps.saveNavigationEntryScroll = (y) => navApiController!.saveScrollPosition(y);
|
|
387
|
-
deps.completeRouterNavigation = () => navApiController!.completeRouterNavigation();
|
|
388
|
-
deps.navigationNavigate = (url, replace) => navApiController!.navigate(url, replace);
|
|
389
376
|
}
|
|
390
377
|
|
|
391
378
|
/**
|