Summary#
Every route has a unit of work, and every edit on the page joins it: a field bound to a row, an action that sets a
field or makes a row, a server call that writes into it on the way back. Nothing is stored until something keeps
it — the person's Save, an AutoSave once they pause, or an action's own UnitOfWork.Commit(). That is the default,
and the page below writes nothing else: it places SaveActions and binds two fields.
entity Job {
[Required, MinLength(1), MaxLength(120)] string Customer;
[Required, MaxLength(120)] string Artwork;
bool Paid;
security { allow read, create, update when IsAnonymous || IsAuthenticated; }
}
[Route("/jobs/{id}")] [Render(CSR)] [AllowAnonymous]
component JobPage(Guid id) {
live var job = Job.FirstOrDefault(j => j.Id == id);
string notice = "";
// Marking a job paid is done AT ONCE — the server call saves as it ends.
action MarkPaid() {
notice = "";
try { Settle(job.Id); notice = "Marked paid."; }
catch (Exception e) { notice = e.Message; }
}
render {
if (job != null) {
Stack(gap: 4, p: 6) {
PageHead(job.Customer) { SaveActions(saved: "Job saved."); }
Field("Artwork", value: job.Artwork);
Field("Customer", value: job.Customer);
Row(gap: 2) {
Button("Mark paid", onPress: MarkPaid);
TextLink("All jobs", to: "/jobs");
}
if (notice != "") { Text(notice, role: UiRole.Status); }
}
}
}
}
[Route("/jobs")] [Render(CSR)] [AllowAnonymous]
component JobsPage() {
live var jobs = Job.OrderBy(j => j.Customer).Take(50).ToList();
render { Stack(gap: 2, p: 6) { foreach (var j in jobs) { Text(j.Customer); } } }
}
// Work the person does not edit field by field is a function — and it saves as it ends.
[AllowAnonymous]
void Settle(Guid id) {
var job = Job.Single(j => j.Id == id);
job.Paid = true;
}Signature#
UnitOfWork.CanUndo() · UnitOfWork.UndoLabel() · UnitOfWork.Undo() · UnitOfWork.Redo() // the steps, read and walked
UnitOfWork.Mark(label) // name a step, or open a group
UnitOfWork.EditCount() // grows with every edit
Validation.All · Validation.Violations · Validation.Reveal() // a save held for its rulesDescription#
Which edits can the person undo, with no code?#
Every one. The platform takes an Undo step for each action a person runs that edits something — named by the
action, so MarkShipped reads Undo mark shipped — and for each field they type into, named by the field.
Keystrokes in one field are one step, so typing a name is one Undo, not one per letter; editing another field, or
running an action, starts the next. An action that edits nothing (it opens a dialog, it navigates) leaves no step, and
neither does one that saves, discards or undoes: those end the history it would have joined. on mount, settle hooks
and on every clocks are the page's own work, not the person's, and take no step.
SaveBar, SaveActions and AutoSave already show the Undo button and its words (Save, discard, undo — three ways to show a page's unit of work); Mod+Z is the same
press. A page with its own button reads UnitOfWork.CanUndo() and UnitOfWork.UndoLabel() as before.
What does UnitOfWork.Mark change, then?#
It takes over the grouping (owner, 2026-10-10: "if the designer sets mark, then that starts the group"). A Mark
taken inside an action replaces that action's step and gives it the words: UnitOfWork.Mark("Add a queue") reads
Undo add a queue. And it OPENS A GROUP: the steps after it join the mark until the next Mark, an Undo or Redo, a
Commit or a Discard. That is what lets a canvas mark once per gesture and have every move that follows undo as one:
action Gesture(string what) { UnitOfWork.Mark(what); } // the canvas raises `gesture` before each edit
action Moved(Step s, double x, double y) { s.X = x; s.Y = y; } // …and these join that mark: one Undo per gestureWhat about a control that raises several events for one act?#
They are one step. Every event a control raises in one synchronous turn — a Delete over a selection raising an
edgeRemoved per edge and a nodeRemoved per node, a fill-down raising a change per cell — is ONE Undo, named by the
first action it ran. A gesture that spans turns (a drag that emits as it goes) is one step when the control wraps it
in host.batch, which names it (control — foreign UI controls (charts, grids, maps)):
// in the shim — the drag emits as the pointer moves; the drop ends the act
host.batch('Move cards', () => new Promise<void>((drop) => {
onMove = (card, x, y) => host.emit('cardMoved', card, x, y);
onDrop = drop;
}));The app writes nothing: the actions its events run join the step. A UnitOfWork.Mark inside one still takes over
the grouping, as above.
Does Undo reach what a control staged on the server?#
Yes. A control that writes straight into the page's unit of work on the server — a MarkdownEditor with
recordScoped: true — gets a step per run of writes, like a field. Undo takes the server's copy back to before the
step, the page reads clean again if nothing else is unsaved, and a Save after it writes nothing of what was undone;
Redo puts it back. The control is told its data moved (host.data.onChanged) and re-reads, so what it shows is what
the record holds. MarkdownEditor does this from 1.2.0; a control of your own subscribes once:
const off = host.data?.onChanged?.(() => { void reload(); }); // and call off() in destroy()What happens when Save is pressed with a field the rules refuse?#
The save is held, not sent. SaveBar, SaveActions and AutoSave look at Validation.All first — every rule
the page's unsaved edits break, whether the person has reached that field yet or not — and when it holds anything they
call Validation.Reveal(), which shows each violation at its field as a save attempt would, and say how many:
1 field needs attention. Every edit stays, and nothing went to the server.
Sent, the same save would only come back refused a round trip later. A refused page save keeps the page's edits
— whether the rules, the server or a lost race refused it: the values stay in their fields, the page stays dirty, and
each field the server named shows its sentence under it until it is edited (Validation). That is the PAGE's
save — a UnitOfWork.Commit() in an action of the page or of a component on it. A commit inside a function the page
calls is that function's own act, and a refusal of it discards what it carried ([[function-unit-of-work#failure]]).
A page with its own Save button does the same check in one line:
action Save() {
if (Validation.All.Count > 0) { Validation.Reveal(); problem = "Some fields need attention."; return; }
UnitOfWork.Commit();
}Validation.Violations is the list the person has been SHOWN (a field reveals its violation once they leave it, or
once a save is attempted); Validation.All is the whole verdict, for deciding.
What happens when the person leaves with unsaved edits?#
The platform asks — Leave without saving? · Keep editing · Save and leave · Discard and leave — for every
way out the page did not guard itself: a link in the page, Navigation.Go from an action, the browser's Back. Keep
editing is the default and what Escape does; the address goes back where it was. Save and leave saves first and stays
put, saying why, if the save is refused. Save is not offered when the page's derived canSave says it cannot be
saved (LeavePrompt — asking before a page with unsaved edits is left). A page kept open by a retaining Outlet(retain: …) is not thrown away by moving to
another tab, so that asks nothing; leaving the layout does ask.
The kit's shells ask in their own look first, with the same three answers (LeavePrompt); once the person has
answered there, the page is clean and the platform has nothing to ask.
Why is an act refused while the page holds unsaved edits?#
A server call a page makes acts at once: it saves what it changed as it ends. When that would change a row the
page holds unsaved edits to, the platform refuses it before anything is written — Save or discard your changes to
this job first — this would change it while it still holds edits you have not saved. — because the alternative is a
stored row that moved under the person's draft, and a Save that then meets a conflict nobody told them about. It
arrives as a ConflictException at the call, so catch (Exception e) shows it and the page carries on.
A row handed to the call is the exception, on purpose: Publish(job) means "publish this job as I have it", so the
row's unsaved edits ride the call and its save keeps them ([[ui-data-mutation#handed-row]]). The refusal is for the
rows the call reaches by itself — an id, a query. In the sample, Settle(job.Id) reaches the job by its id, so it
waits for the person's edits; Settle(job) would have taken them with it.
How does AutoSave know the person has paused?#
UnitOfWork.EditCount() grows with every edit the unit of work takes, a keystroke into a bound field included, and
never goes back. A reader that remembers the last count it saw can tell a pause from typing: AutoSave counts its
seconds from the latest edit, not the first, so nothing is kept mid-word. The same read answers any "saved a moment
after you stop" a page builds for itself.
How do I test the whole journey?#
Drive the page and read the row back after each step — a control that says "Saved" and kept nothing is exactly what these rule out:
entity Job {
[Required, MinLength(1), MaxLength(120)] string Customer;
[Required, MaxLength(120)] string Artwork;
bool Paid;
security { allow read, create, update when IsAnonymous || IsAuthenticated; }
}
[Route("/jobs/{id}")] [Render(CSR)] [AllowAnonymous]
component JobPage(Guid id) {
live var job = Job.FirstOrDefault(j => j.Id == id);
string notice = "";
action MarkPaid() {
notice = "";
try { Settle(job.Id); notice = "Marked paid."; }
catch (Exception e) { notice = e.Message; }
}
render {
if (job != null) {
Stack(gap: 4, p: 6) {
PageHead(job.Customer) { SaveActions(saved: "Job saved."); }
Field("Artwork", value: job.Artwork);
Field("Customer", value: job.Customer);
Button("Mark paid", onPress: MarkPaid);
if (notice != "") { Text(notice, role: UiRole.Status); }
}
}
}
}
[AllowAnonymous]
void Settle(Guid id) {
var job = Job.Single(j => j.Id == id);
job.Paid = true;
}
[Test]
void an_edit_is_undone_and_a_held_save_keeps_the_edits() {
var j = new Job { Customer = "Cy Lund", Artwork = "Two etchings" };
UnitOfWork.Commit();
Ui.Visit("/jobs/" + j.Id);
Ui.Fill("Artwork", "Three etchings");
Ui.Click("Undo artwork");
Assert.Value("Artwork", "Two etchings");
Ui.Fill("Artwork", "Three etchings");
Ui.Fill("Customer", "");
Ui.Click("Save");
Assert.Visible("1 field needs attention.");
Assert.Value("Artwork", "Three etchings");
Assert.Equal("Two etchings", Job.Single().Artwork);
}
[Test]
void an_immediate_act_waits_for_the_unsaved_edits() {
var j = new Job { Customer = "Cy Lund", Artwork = "Two etchings" };
UnitOfWork.Commit();
Ui.Visit("/jobs/" + j.Id);
Ui.Fill("Artwork", "Three etchings");
Ui.Click("Mark paid");
Assert.Visible("Save or discard your changes to this job first");
Assert.False(Job.Single().Paid);
Ui.Click("Save");
Ui.Click("Mark paid");
Assert.Visible("Marked paid.");
Assert.True(Job.Single().Paid);
}The kit's own suite runs the rest of the journey — leaving with each of the three answers, and an AutoSave that
waits for a pause (Platform.Core/Dsl/OsySharp/Baseline/Capabilities/Ui/tests/unit-of-work.test.osy).
See also#
- Save, discard, undo — three ways to show a page's unit of work — the three ways a page offers to keep its unit of work
- LeavePrompt — asking before a page with unsaved edits is left — the kit's leave prompt, and
derived canSave - creating & saving data — how an edit joins the unit of work, and a row handed to a function
- UnitOfWork savepoints — Mark, Undo, Redo, RollbackTo —
Mark,Undo,Redo,RollbackToon both sides