// A FORM THAT FAILS KEEPS WHAT WAS TYPED (release 16, slice FK). // // React resets a `
` when its action's transition commits — success or // failure alike. `startHostTransition` calls `requestFormReset` beside the // action itself, and the commit ends with a native `form.reset()` (react-dom, // `recursivelyResetForms`). An UNCONTROLLED field (`defaultValue`, // `defaultChecked`) goes back to its default; so before this, every editor form // wiped what the operator typed the moment its action returned an error. // // The fix is a data contract, not a widget: an action that can fail captures // what was submitted at its top (`formValues`) and returns it beside the error, // and every uncontrolled field seeds its default from it (`seedValue`, // `seedChecked`). React writes the new default in the same commit, before the // reset runs, so the reset puts the typed value back. A success returns no // values, so a field seeds from its initial value — the stored one — exactly // as before. // // PURE, AND IMPORTED BY BOTH SIDES: the server actions build `values` with it // and the client forms read it. No React, no node: — a "use server" module and // a client bundle both import this file. // What a form posted, as a failed action hands it back: each name's FIRST // value, files skipped (no editor form posts one, and a File does not survive // the trip back to the client). export type FormValues = Record; // The `{ ok }` flavour, which most save actions return. `T` is what a success // carries (`{ siteId }`, `{ note }`, …). export type FormFailure = { ok: false; error: string; values?: FormValues }; export type FormState = ({ ok: true } & T) | FormFailure; // The channels flavour: `undefined` is success (the action redirects, or there // is nothing to say), an object is the refusal. export type FormErrorState = { error: string; values?: FormValues } | undefined; // EITHER FLAVOUR, as the seeding helpers read it — one implementation for both. // A state is a failure unless it says `ok: true`; the channels flavour has no // `ok` at all, and its success is `undefined`. export type SeedSource = | { ok?: boolean; error?: string; values?: FormValues } | null | undefined; export function formValues(formData: FormData): FormValues { const values: FormValues = {}; for (const [name, value] of formData.entries()) { if (typeof value !== "string") continue; if (Object.hasOwn(values, name)) continue; // defineProperty, not assignment: a field named `__proto__` would otherwise // hit the prototype setter and vanish. Object.defineProperty(values, name, { value, enumerable: true, writable: true, configurable: true, }); } return values; } // The values a failed submit carried back, or undefined (no submit yet, a // success, or an action that returned no values). export function failedValues(state: SeedSource): FormValues | undefined { if (!state || state.ok === true) return undefined; return state.values; } // A text, number, textarea, select or radio-group field's default: what was // submitted when the last submit failed, else `initial`. A name the failed // submit did not carry was not submitted at all (a disabled field, or one not // rendered then), so it keeps `initial` rather than going blank. export function seedValue( state: SeedSource, name: string, initial: string, ): string { const values = failedValues(state); return values && Object.hasOwn(values, name) ? values[name] : initial; } // A checkbox's default: ticked when the last failed submit carried its name, // else `initial`. AN UNTICKED BOX SENDS NOTHING, so once `values` is present // absence means unchecked — not "unknown, use the initial". export function seedChecked( state: SeedSource, name: string, initial: boolean, ): boolean { const values = failedValues(state); return values ? Object.hasOwn(values, name) : initial; }