Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

A page's unit of work — what the platform does for an editing page, end to end

every edit on a page joins its unit of work · one Undo step per action and per field · UnitOfWork.EditCount() · Validation.All / Validation.Reveal() · a leave guard · an immediate act refused while edits are unsaved

A page that edits rows writes no save machinery. Every edit joins the page's unit of work; each action a person runs and each field they type into is an Undo step without a `UnitOfWork.Mark`; a save the model's rules would refuse is held with the edits kept; leaving the page asks first; and a server call that would change a row the page holds unsaved edits to is refused until they are saved or discarded. This page walks the whole journey with one sample.

stable2 examples compiled by CIuikitdataunit-of-work

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 rules

Description#

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 gesture

What 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#

Related

Save, discard, undo — three ways to show a page's unit of work

Every edit on a page joins the route's unit of work; how the page OFFERS to keep it is a choice of presentation…

LeavePrompt — asking before a page with unsaved edits is left

When somebody presses another row in the sidebar shell's rail while the page holds unsaved edits, the shell asks first:…

creating & saving data

A UI `action` creates, updates and deletes data by writing `new Entity { … }`, assigning fields, and calling…

UnitOfWork savepoints — Mark, Undo, Redo, RollbackTo

Marks form a stack on the unit of work. UnitOfWork.Mark(label) records everything staged so far and answers a…

Navigation

The routes the user has open, which of them hold UNSAVED edits, and the verbs that move between them or close one…