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

> 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.

<!-- id: ui-page-unit-of-work · area: ui · stability: stable · html: https://osysharp.com/reference/ui/page-unit-of-work/ -->

## Summary        {#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.

```osy title="one page, the whole journey — edits, undo, a held save, leaving, an act that waits" test app=ui-page-uow
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      {#signature}

```osy syntax
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    {#description}

### Which edits can the person undo, with no code?   {#undo}

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](https://osysharp.com/reference/ui/saving/)); 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?   {#mark-groups}

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:

```osy syntax
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?   {#control-acts}

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)](https://osysharp.com/reference/ui/controls/)):

```ts syntax
// 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?   {#server-staged}

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:

```ts syntax
const off = host.data?.onChanged?.(() => { void reload(); });   // and call off() in destroy()
```

### What happens when Save is pressed with a field the rules refuse?   {#held-save}

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](https://osysharp.com/reference/ui/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:

```osy syntax
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?   {#leaving}

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](https://osysharp.com/reference/ui/leave-prompt/)). 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?   {#immediate}

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?   {#pause}

`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?   {#test}

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:

```osy title="the journey, asserted on the row" test app=ui-page-uow-test
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       {#see-also}

- [Save, discard, undo — three ways to show a page's unit of work](https://osysharp.com/reference/ui/saving/) — the three ways a page offers to keep its unit of work
- [LeavePrompt — asking before a page with unsaved edits is left](https://osysharp.com/reference/ui/leave-prompt/) — the kit's leave prompt, and `derived canSave`
- [creating & saving data](https://osysharp.com/reference/ui/data-mutation/) — how an edit joins the unit of work, and a row handed to a function
- [UnitOfWork savepoints — Mark, Undo, Redo, RollbackTo](https://osysharp.com/reference/function/unit-of-work-savepoints/) — `Mark`, `Undo`, `Redo`, `RollbackTo` on both sides
