Staged-edit forms over Ankurah entities, for
Leptos. A Rust port of the staged-edit semantics in
ankurah-react-forms (ankurah issue #452).
An Ankurah entity is live: a property read gives you what the entity says right now, and a remote writer can change it while the user is looking at it. That makes the obvious ways to build an editable field wrong in two directions at once.
Commit on every keystroke, and you fill the event log with garbage and race
every other writer. This is what a Leptos app tends to grow first — see
community/leptos-app's editable_text_field.rs, whose on_change fires per
keystroke and whose callers open a transaction in it.
Copy the value into a local draft when editing starts, and you freeze the whole entity: every other field on the form stops showing remote updates for as long as the user has the form open.
A staged-edit form is the middle. Keystrokes go into an overlay, and only the fields that have an overlay entry stop tracking the entity. Every other field keeps updating live. On save, one transaction writes only the fields whose staged text still differs from the entity — so a form open on five fields and edited on one cannot clobber the other four.
- A field displays its staged text if the user typed into it, and the entity's live value otherwise.
- A field is dirty when it has staged text that still differs from the entity. Only dirty fields are written on save.
- When the entity catches up with staged text — the save landed, or another writer typed the same thing — that entry is dropped, and the field goes back to following the entity. Without this the stale entry would win over the next remote update.
- Pointing a form at a different entity — or at no entity — discards text typed into the previous one. A keystroke, a save, and a save's completion each read the entity signal when they happen, so all three act on what the form is pointed at then, rather than on what the effect that watches the signal last caught up with.
- Leaving edit mode discards staged text.
- A save writes a snapshot of the dirty fields, and when it lands it retires only the entries still holding what it wrote, on the entity it wrote to. Anything typed while the commit was in flight stays staged — including text matching what the entity says at that moment, because that is the value the commit is about to overwrite, so typing it back is a request to keep it. It reads dirty as soon as the commit lands, and the next save puts it back.
- A save that fails leaves the entity untouched, so it drops the staged text the entity already holds. Text that still differs stays, for the user to fix and retry; text the entity agrees with has nothing left to ask for, and keeping it would only let it win over the next writer to change that field.
- A form that is not editing refuses keystrokes outright.
FormMode picks what a form allows: Read (display only), ReadWrite (display
first, edit on request — the default), Write (always editing). EditTrigger
picks the gesture that starts editing in ReadWrite: Field (click a field —
the default), Form (click anywhere in the form), Explicit (only a call to
start_editing, from your own edit button).
use ankurah_leptos_forms::{CancelButton, FieldText, SaveButton, StagedForm, TextField, TextInput, TextProperty};
use leptos::prelude::*;
#[component]
pub fn RoomHeader(room: RwSignal<Option<RoomView>>) -> impl IntoView {
let form = StagedForm::new(
ctx(),
room.into(),
[
TextField::new("name", |v: &RoomView| v.name().field_text(), |m, text| m.name().stage_text(text)),
TextField::new("topic", |v: &RoomView| v.topic().field_text(), |m, text| m.topic().stage_text(text)),
],
);
view! {
<div class="roomHeader" on:click=move |_| { form.container_clicked(); }>
<TextInput form=form field="name" label="Name"/>
<TextInput form=form field="topic" label="Topic" placeholder="Add a topic…"/>
<Show when=move || form.editing()>
<CancelButton form=form/>
<SaveButton form=form/>
</Show>
{move || form.error().map(|failed| view! { <p class="formError" role="alert">{failed.message().to_owned()}</p> })}
</div>
}
}tests/binding.rs compiles a copy of the body above — the same form, the same
two fields, the same view — so the API this example uses cannot change out from
under it without a test failing. It is a transcription, not an include: nothing
mechanically ties the two, and the #[component] wrapper a consumer actually
writes is compiled nowhere. Change one and change the other.
Nothing there names the model. Annotating each read closure with its view type settles the model, the mutable type and both property types for the whole binding.
Nothing there names replace or set either. name is a bare String, so
ankurah gives it a text CRDT and stage_text calls replace; topic is an
Option<String>, so it is a last-writer-wins register and stage_text calls
set. React-forms picks between them by asking a JS object at runtime whether
it has a replace method. Here the property's type selects the impl, at compile
time, and the call site is identical either way.
There is none in this crate, on purpose. Install ReactiveGraphObserver once at
startup (community/leptos-app does it at src/main.rs:143) and an Ankurah
property read registers the entity as a dependency of whatever Leptos scope is
running. Reading a field in a view is the subscription. This crate ports the
staged-edit semantics and nothing else.
There is no styling, and no way to inject a design system's components — the React original has slots for that, and Leptos components cannot be swapped out the same way. Instead the controls carry the same data attributes the React original used, so a stylesheet drives the whole thing:
| attribute | on | when |
|---|---|---|
data-field="<key>" |
wrapper | always |
data-field-type |
wrapper | always: whatever input_type was given, defaulting to text, and always textarea on a TextArea |
data-editing |
wrapper | the form is in edit mode |
data-dirty |
wrapper, label, control | this field would be written by a save |
data-error |
wrapper, control | the last save failed on this field |
aria-invalid |
control | the last save failed on this field |
data-can-edit |
wrapper | clicking this control would start editing |
Each is present-or-absent, never ="false", so [data-dirty] selects. No class
is added to anything, so a stylesheet's own classes are the only ones present —
pass one through the class prop and it lands on the wrapper.
That table is the whole set, and a stylesheet ported from React will find three
of its hooks missing. React puts data-editing on the control as well as the
wrapper; here it is on the wrapper only, so [data-editing] input reaches what
input[data-editing] used to. React marks a field that renders an icon with
data-has-icon, and this crate renders no icons. React replaces the control
entirely in display mode when the value is empty and an emptyText was given,
emitting a <span data-field-empty> instead — there is none of that here, and an
empty field displays as an empty control. In the other direction, data-field
carries the field's key here where React leaves it empty, so [data-field="topic"]
selects one field.
A control that does not fit is about fifteen lines against StagedForm
directly — displayed, is_dirty, stage, editing — and behaves identically,
because none of the rules live in the components.
Published crates from crates.io, not path dependencies:
ankurah = "=0.9.0"
ankurah-signals = { version = "=0.9.0", features = ["reactive-graph"] }
leptos = "0.8.12"
web-sys = { version = "0.3", features = ["HtmlElement"] }Both target consumers — community/leptos-app and ankurah-leptos-template —
declare the same ankurah pair and the same leptos range, so matching them is
what makes this crate droppable into either without disturbing their lock
files. It also lets the crate build and
test standalone on the host target, with no ankurah checkout nearby and no
browser: the whole rules layer is plain Rust, so cargo test exercises it
directly.
Only ankurah is pinned. leptos = "0.8.12" is a caret range, so it means 0.8.12
or later within 0.8, and the two sides resolve differently: the consumer's lock
sits at 0.8.14 today, while a fresh resolve here — this repo does not commit a
lock file — picks 0.8.20. So a green cargo test in this repo says the source
compiles against 0.8.20, not against what the consumer builds. Keep the source
compilable against 0.8.12, the floor both sides declare: nothing here may depend
on a leptos API added after it.
web-sys = { version = "0.3", features = ["HtmlElement"] } is declared
directly, for the one browser call here: HtmlElement::focus, so that clicking
a field to start editing also puts the cursor in it. Leptos re-exports web-sys,
and this crate reached it that way for a while, but a re-export is not a
supported surface — upstream publishes it for its own macros to expand into,
not as an API downstream crates should depend on, and it can move without that
being a breaking change to anything leptos promised. Two independent reviews
asked for the direct dependency; this is it.
Declaring it costs nothing in version management, which was the original reason
for going through the re-export. The range is a loose 0.3, so Cargo unifies it
with whatever web-sys the consumer's lock already resolved — and the consumers
pin the wasm-bindgen family exactly, because ankurah-signals 0.9.0 breaks on
newer ones, so what they pinned is still what this crate compiles against. Two
web-sys copies would break the build rather than pass silently: the bound says
leptos's own NodeRef output implements AsRef<web_sys::HtmlElement>, and a
type from a second copy of web-sys is a different type. All 0.3.x are
semver-compatible, so Cargo resolves one.
The JsCast bound two lines away still reads leptos::wasm_bindgen::JsCast,
and this round left it alone. It is the same question with a different answer to
weigh — a second copy of wasm-bindgen would make it a different trait — and
nothing forced it, so it was not reopened.
Both build targets are checked:
cargo test # host: the rules, and the bindings
cargo check --target wasm32-unknown-unknown # what the consumers ship
The wasm build needs three things that the host build does not, and the
manifest declares all of them under [target.'cfg(target_arch = "wasm32")'],
matching what both consumers already set:
ankurah'swasmfeature. Not optional:ankurah-core0.9.0 callswasm_bindgen_futures::spawn_localon this target, and that feature is what brings the crate in. Without itankurah-coredoes not compile for wasm at all.getrandom0.3 withwasm_js, and 0.2 withjs. Neither is used by any code here; ankurah pulls them in transitively, and both refuse to build for wasm until told which browser backend to use.- The
getrandom_backend="wasm_js"rustflag, in.cargo/config.toml, scoped to the wasm target so the host build the tests run on is left alone.
None of it changes how the crate builds inside a consumer, which has its own copies of the same settings; it is what lets this repo be checked on its own.
Fields bind by closure, not by name. React-forms writes <Field name="email"/>
and reflects on the entity at runtime. Rust has no reflection, so a field carries
a read closure and a write closure instead. The compiler then checks that the
property exists and that the value has the right type — the things reflection
gets wrong at runtime. The per-field API is deliberately narrow: a key, a read,
a write. When ankurah grows property metadata (ankurah#289), a generated field
list can replace a hand-written one without any component or form API changing,
because components only ever name a field by its key.
No create mode. React-forms uses its write-only mode both for editing an
existing entity and for creating a new one. Here FormMode::Write only means
"always editing", and always of an entity that already exists. Creating needs
trx.create(&Model { .. }), i.e. a whole model value assembled from staged
strings, which two closures per field cannot produce. Adding it would mean a
third closure — a Fn() -> M default — plus the create-then-edit handover; it
is a coherent extension, just not one this port makes.
Create the entity yourself and hand it over. The handover is two lines on top of the create you would write anyway, and the create is where the compiler checks that every required field has a value — which a form assembling strings cannot do:
let trx = ctx.begin();
let created = trx.create(&Room { name: "General".into(), topic: None, slug: "general".into() }).await?;
let room = created.read(); // a View over the new entity; `read` is on ankurah's `Mutable`
trx.commit().await?;
entity.set(Some(room)); // the signal the form was built on
form.start_editing();The reconcile effect sees the entity arrive and rebases the form onto it, and the user edits a real entity from there.
Only text fields. React-forms also has checkbox, select, and number. Each
needs its own staged type and its own property dispatch; String covers the
inputs the consumer actually has today, and the overlay is typed to String
throughout rather than pretending to be generic over a value type it has never
been tested against.
No embedded-struct dot notation. React-forms supports name="address.city",
grouping keys by root and merging into the existing struct on save. A closure
lens does not need it: the read closure can reach into a struct and the write
closure can merge, and if it does, the form is none the wiser.
No refTransforms. React-forms converts entity references to and from
display strings with async transform functions. That is a lookup, not a form
concern; a caller who needs it can hold the resolved value in its own signal.
No submit timeout. React-forms shows "Save failed" after five seconds and stops waiting. It does not cancel the commit, so the message can be wrong. This crate reports what actually happened, whenever it happens.
Keystrokes are refused when not editing. React-forms relies on the
disabled attribute and has no guard underneath. Here the rule is in the state,
so a hand-written control that forgets to check editing() cannot quietly
accumulate staged text in a read-only form and then offer to save it.
An error is attributed to a field only when the form knows which one. A
failed property write names its field, because the form knows what it was
applying. A refused commit does not: ankurah 0.9 reports it as a message, and
react-forms recovers a field name from it by matching /missing field ...?/ on
the error string. Guessing from message text puts an error marker on a field at
random as soon as the wording changes, so this port does not do it.
No per-field subscription from a View. Reading any property registers the entity's broadcast, not the property's, so a change to any field wakes every field's scope. Everything still displays correctly — it is a wasted-work problem, not a correctness one — and it is the known ankurah limitation this crate cannot route around.
Save errors are strings. Ankurah 0.9 reports most failures as a message with
no machine-readable detail; the structured errors are on an unmerged branch. So
SaveError exposes behaviour rather than data — kind(), message(),
field(), and the underlying error through std::error::Error::source — and
SaveErrorKind is #[non_exhaustive]. When the structured errors land, the
enum grows variants and richer accessors appear, and callers that already match
with a _ arm keep compiling.
A read failure displays as empty text. FieldText turns a property that
will not read into "", and reports nothing. This matches react-forms, whose
display value is view[name] ?? "", and it is defensible for a display lens —
a property with no readable value has no text to show — but a deserialization
failure looks exactly like an empty field. A consumer that needs to tell them
apart writes its own read closure.
Such a field stays editable and savable, deliberately: it displays "", what
the user types is compared against "" to decide dirtiness, and a save writes
the typed text over the property that would not read. That is the repair path —
a property holding something undecodable can be typed back into shape from the
form. A field nobody touches contributes nothing to any save, so the unreadable
value is left exactly as it is until somebody chooses to overwrite it.
An optional string cannot hold Some("") through stage_text. Clearing the
input writes None. This matches react-forms, which normalizes "" to null
before every write, and matches what the Leptos consumer's room topic already
does by hand. A model that means something by an empty-but-present string must
write its own closure calling set directly.
A misdeclared field panics. A component whose field key is not in the list
passed to StagedForm::new panics with a message naming the key, on the first
render, with or without an entity bound. A list that declares one key twice
panics in StagedForm::new itself, before the form exists — two bindings under
one key would put the single value the user typed onto two properties. Both are
wiring mistakes, and in a wasm build a panic takes the app down — but a silently
blank field, or a save writing somewhere nobody asked it to, is worse to find,
and the alternative needs a logging dependency to say anything at all.
Nothing tests the signal layer. The rules underneath are covered — 75 unit
tests over the overlay, FormState, the mode machine, the error type and the
property lenses — and the property dispatch is checked against a real derived
model. The StagedForm wrapper around them is not: stage, displayed,
is_dirty, has_dirty, start_editing, stop_editing, field_clicked,
container_clicked, with_mode, with_edit_trigger, save and submit have
no test that calls them, and neither does the effect that follows the entity.
Building a form needs a Context, and therefore a node and a storage engine,
plus a Leptos reactive owner and an async runtime — and a save reaching storage
needs all of that again. It belongs in an integration suite alongside the
consumer rather than in this crate's cargo test. What is pinned instead is
every rule the wrapper delegates to, and StagedForm::new's duplicate-key
check, which runs before any signal is built.
That includes the order the wrapper calls those rules in. stage, save,
submit and both of a save's completion arms read the entity signal and rebase
the overlay before they stage, select, or judge whose entity a completion
belongs to — which is what stops a save from carrying one entity's text to
another, and what stops a save landing after the selection changed from ending
an edit on the entity that replaced its own. The state tests pin every
consequence of that order, as sequences of FormState calls, but nothing here
checks that StagedForm actually makes the calls in it. A host test would.
The form-level edit trigger has to be wired by hand. EditTrigger::Form
means "clicking anywhere in the form starts editing", but this crate renders no
form container to attach that to. Call container_clicked() from whatever
element wraps your fields, as the usage example does. React's form-blur
behaviour is missing for the same reason: react-forms puts onBlur on its
<form> and leaves edit mode when focus goes somewhere outside it and no field
is dirty. With no container rendered here there is nothing to hang that on, and
the crate does not try — a consumer that wants it calls stop_editing() from
its own wrapper's blur handler, after checking has_dirty().
Duplicate id attributes across forms. A control sets id to the field key
so its label's for can point at it. Two forms on one page binding the same key
produce duplicate ids. React-forms has the same behaviour.