# 5. Saving

> How a page writes itself back to its file: the save path, autosave, the save API, conflicts, hooks and save status.

Source: https://clayjs.com/docs/guide/saving/

## Edit mode and the save path

In edit mode, ClayJS saves the whole document to `POST /_/save` on the page's origin (or `/_/save/<savetoken>` on HTML Clay). Cmd+S (Ctrl+S elsewhere) and clicks inside any `[trigger-save]` element call `clay.save()` on every edit-mode page, with or without autosave. A close warning appears whenever there are unsaved changes.

### The saved title

ClayJS derives the saved `<title>` from the document rather than keeping the authored `<title>`. The save projection reads the captured model and takes the first top-level H1, collapsing and trimming whitespace runs. A page with no top-level H1 gets the title `Untitled`. The server checkpoint renderer runs the same function on the same model, so a room checkpoint writes the same title a client save would.

To keep an authored `<title>`, put `<meta name="clay-seo-title-mode" content="custom">` in `<head>`. ClayJS then leaves `<title>` untouched. Setting a custom title in the SEO panel writes that marker, and removing it restores the derived title on the next save.

Title changes only update the document's content. ClayJS never derives or changes a file's name. A save writes bytes to the route the host already serves, so the file name, downloaded name, and local folder path stay the same. Renaming a file is up to the host or the user.

## Autosave

`<html autosave>` present at load turns on autosave. Adding or removing it later changes nothing.

- A watched DOM change or a `persist` input starts a 1500 ms debounce. Continuous changes save at most 10 s after the first one. Saves are throttled to one per 1.2 s.
- Measured: one edit saves at about 1.5 s, 50 edits in a second save once at about 2.5 s, and an edit every 300 ms saves at about 10 s.
- Template content edits never trigger autosave.
- A save that fails (500 or network) is **not retried** until the next edit or the browser's `online` event. `savestatus="error"` stays up. If your app can sit idle after a failure, retry yourself:

  ```js
  document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden" && document.documentElement.getAttribute("savestatus") === "error") clay.save();
  });
  ```

**The load-settle window.** At load, ClayJS takes a baseline of the document. It retakes the baseline once the page is quiet for 500 ms (at most 3 s), unless the user typed or a save ran. It then sets `savestatus="saved"` and fires `clay:baseline-settled`. Anything app code writes to the record during that window becomes part of the baseline: it is not saved, not autosaved, and not counted as unsaved. It reaches the file only when a later edit triggers a save:

- Do not seed defaults, migrate a schema, or stamp ids at startup. Author defaults in the file.
- If an app must write at startup, write after `clay:baseline-settled` (or once `<html savestatus>` exists), or follow the write with `await clay.save.force()`.

## Manual-save pages and batching

To disable autosave, such as for a long-form writer where frequent saves are unwelcome, omit `autosave`. Cmd+S, `[trigger-save]`, and the close warning still work. Save when the tab hides:

```js
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden" && clay.isEditMode && clay.hasUnsavedChanges()) {
    clay.save.flush({ keepalive: true }).catch(() => {});
  }
});
```

Keep autosave on for collaborative pages. Peers see an edit only after it saves ([What sync is](https://clayjs.com/docs/guide/live-sync/#what-sync-is)).

To batch saves in part of an autosaving page, wrap that region in `clay="no-trigger-autosave"`. Edits there still count as unsaved and warn on close, but never start a save. Your own `clay.save()` (on blur, or every 30 s) writes them, as does any save triggered by another edit.

`clay.Mutation.pause()` and `resume()` hide DOM mutations from the mutation hub's listeners, including autosave's mutation trigger, while paused. The next save still writes those mutations. They do not pause input: `input` and `change` events on controls, including synthetic ones, still mark the page dirty and start autosave. Pair them in `try/finally`. An unbalanced pause silences mutation-driven autosave for the rest of the session.

## The save API

`clay.save()` returns `Promise<{ ok, msg, msgType, code, etag }>` and never rejects. One exception in 1.8.2: when capturing the page fails because a registered hook threw ([Hooks](https://clayjs.com/docs/guide/saving/#hooks)), the result has `msgType: "error"` and no `ok` field.

| Result | Meaning |
|---|---|
| `ok: true`, `msgType: "success"` (or `"warning"`) | The host accepted these bytes |
| `ok: false`, `msgType: "skipped"`, `msg: "No changes to save"` | Nothing in the dirty domain changed |
| `ok: false`, `msgType: "skipped"`, `msg: "Save already in progress"` | A save is in flight; these bytes are queued and sent after it |
| `ok: false`, `msgType: "conflict"` | HTTP 412: the file changed elsewhere ([Conflicts on save (412)](https://clayjs.com/docs/guide/saving/#conflicts-on-save-412)); also carries `changedBy`, `afterTimeout`, `conflictEtag` |
| `ok: false`, `msgType: "error"` | The host refused or the network failed |
| `ok: false`, `msgType: "unknown"` | 12 s timeout; ClayJS asks the host whether it landed |

**Branch on `msgType`, not `ok`.** `ok: false` also means "nothing to save" or "queued behind another save". To ensure the host holds what the page holds now, use `flush`:

- `clay.save.flush({ keepalive, timeoutMs })` resolves `{ state: "view" | "clean" | "saved", etag }` only when the host holds the current bytes. It waits out the settle window and any in-flight save, and retries while the page keeps changing. It rejects with `err.state` of `failed`, `conflict`, or `blocked`.
- `clay.save.force()` saves without the dirty check, writing `no-dirty` and `no-watch` edits. Template content edits need no force: `save()` and the close warning see them, and only autosave does not.
- `clay.save.overwrite()` re-reads the host's current version stamp and force-saves over it. This is "keep mine" after a conflict.
- `clay.getHTML()` returns the exact bytes a save would send. It runs your `onbeforesnapshot` and `onbeforesave` hooks and closes the open undo batch, so do not call it on every keystroke.
- `clay.hasUnsavedChanges()` is synchronous and costs one full capture of the document.
- `clay.registerUnsavedState({ id, isPending })` registers work held outside the DOM (such as an unapplied form) so the close warning and `flush` respect it. Call the returned `changed()` when it changes and `dispose()` when done.
- `clay.markDirty()` tells the dirty gate about an edit no mutation shows, for an editor whose surface is `editor-ui` and which writes the record itself.

## Size limits

The limit comes from the host's `/_/meta` answer, `document.maxBytes`: the most bytes of uncompressed UTF-8 HTML one save body may have, inclusive. It counts only as a positive safe integer. A missing, zero, negative, fractional or non-numeric value means the limit is unknown, and `clay.save.limits()` resolves `{ maxBytes: null }`. Draft mode answers the same without asking the host at all. See [Hosts, assets and privacy](https://clayjs.com/docs/guide/hosts/#what-each-host-gives).

A save is measured on the exact UTF-8 bytes it is about to send, after hooks, transforms and cleanup, and refused before anything is sent when it is over. The result is `{ ok: false, code: "save-too-large", sent: false, size }`, where `size` holds `{ bytes, maxBytes, state: "over" }`. Nothing is written: no accepted listener runs, no baseline moves and no recovery copy is cleared. A host that answers 413 maps to the same code.

The host stays authoritative. A client that predates this check, a first save sent before discovery answers, and a limit that changed after it was read are all decided by the host's own refusal, which leaves the writing recoverable.

`clay.save.inspect(html)` measures bytes that are not this page, such as a file you are about to import, without capturing, sending or moving a baseline:

```js
const size = await clay.save.inspect(candidateHTML);
if (size.state === "over") return say("That is larger than this host can save.");
```

A real save that measures `near` or `over` fires `clay:save-size` and loads a small notice into the editor UI. It reports the last measurement, never a live size:

- Near: `This file is getting large. At the last save attempt: 1.6 MB of 2.0 MB. Download a copy, then reduce its size; saving still works.`
- Over: `This host can't save a file this size. At the last save attempt: 2.1 MB of 2.0 MB. Download a copy to keep your work.`

**Download my copy** appears only when the measurement is over. The notice is hidden by a later save measurement below the threshold, and when the page leaves edit mode. It is loaded only when a measurement is near or over, so most pages never fetch it.

To make a copy of the page without some of its records, remove them from the clone with `clay.getHTML({ transform })`:

```js
const html = clay.getHTML({
  transform: (root) => {
    for (const el of root.querySelectorAll("#entries > article.entry[data-drop]")) el.remove();
  },
  flushUndo: false
});
```

The transform must be synchronous and its return value is ignored. The live page, the baselines and the network are untouched. See [Importing and exporting records](https://clayjs.com/docs/guide/architecture/#importing-and-exporting-records).

## Conflicts on save (412)

On hosts that announce `conditional` (all three hosts in [What the host does](https://clayjs.com/docs/guide/model/#what-the-host-does)), every save sends `If-Match` with the version the tab last saw. If the file changed elsewhere and live sync has not merged that change yet, the host answers 412 and writes nothing.

- The save returns `msgType: "conflict"`, sets `<html savestatus="conflict">`, and fires `clay:save-conflict`. Unsaved work stays in the page. **Autosave stops** until the conflict is resolved, and Cmd+S is refused again.
- ClayJS shows a built-in notice with three actions: **Keep mine** (`clay.save.overwrite()`), **Accept theirs** (press twice; reloads and drops local edits), and **Download my copy** (an HTML file with your version and a list of replaced edits).
- With `sync` loaded, most 412s resolve themselves. The merge brings in the version that beat this tab and fires `clay:save-conflict-resolved`.
- The first save after load can go out without `If-Match` on a slow host because version discovery is not awaited. That save is last-write-wins.
- If you hide the built-in notice, you must still offer overwrite and reload.

## Hooks

Hooks shape the saved bytes without touching the live page. Except for `onaftersave`, they run on a clone.

| Hook | Runs on | Use for |
|---|---|---|
| `onbeforesnapshot="code"` attribute | The clone, for every snapshot: save, change check, sync broadcast | Normalizing tab-local state out of anything that leaves the tab |
| `onbeforesave="code"` attribute | The save clone and change-check clones (not sync broadcasts) | Cleaning a subtree before it is written |
| `clay.onSnapshot(fn)` | Every snapshot; `fn(clone, { original })` | Stripping library residue everywhere |
| `clay.addDocumentTransform(fn)` | The save clone and every change check | Writing derived values into the saved copy only |
| `onaftersave="code"` attribute | The **live** element after each accepted save | Post-save live DOM updates (rarely) |

In attribute hooks, `this` is the cloned element (or the live element in `onaftersave`). Here, the finance tracker keeps sap's search filter (rows hidden with `hidden`) out of the file and out of peers' copies:

```html
<tbody items="txns" onbeforesnapshot="this.querySelectorAll(':scope > [item][hidden]').forEach((row) => row.removeAttribute('hidden'))">
```

Rules:

- **Hooks must be pure and repeatable.** They run on every autosave check, every `hasUnsavedChanges()`, and every `getHTML()`. A hook that writes a timestamp or counter makes the page permanently dirty and autosaves forever.
- A throwing **attribute** hook aborts only that hook. A throwing `clay.onSnapshot` or `clay.addDocumentTransform` callback aborts the capture: `getHTML()` throws and every `save()` fails with `msgType: "error"` until it stops throwing. Registered callbacks must handle missing elements themselves, because change-check captures have already removed `editor-ui`, `no-save`, and `no-snapshot` regions.
- `onaftersave` writes to the live DOM, so any record change it makes counts as a new edit and causes a save loop with autosave. Mark what it writes `editor-ui`, or write inside a `freeze` scratchpad.
- `clay:snapshot-ready` listeners receive the shared clone and must not change it.

Strip library residue once, at the snapshot level:

```js
clay.onSnapshot?.((root) => {
  for (const el of root.querySelectorAll(".sortable-chosen, .sortable-ghost, .sortable-drag")) {
    el.classList.remove("sortable-chosen", "sortable-ghost", "sortable-drag");
    if (!el.classList.length) el.removeAttribute("class");
  }
  for (const el of root.querySelectorAll('[draggable="false"]')) el.removeAttribute("draggable");
  for (const el of root.querySelectorAll('[style=""]')) el.removeAttribute("style");
});
```

Only strip `draggable="false"` if your own markup never authors it.

## Save status

ClayJS reflects save state on `<html>`, so you can style a status indicator with CSS alone:

```css
html[savestatus="saving"] #status::after { content: "Saving…" }
html[savestatus="error"] #status::after { content: "Not saved" }
html[savestatus="conflict"] #status::after { content: "Changed elsewhere" }
```

`savestatus` records what the last save did and keeps its five values. A second root attribute, `unsaved="true"`, tracks whether there is new work that a save has not written yet. It stays on `<html>` from the first edit until a save accounts for it. That split keeps a saved document's status CSS accurate while a separate chip shows unsaved work:

```css
.chip-unsaved { display: none }
html[unsaved="true"]:not([savestatus="error"]):not([savestatus="offline"]) .chip-unsaved { display: inline }
```

In draft mode, nothing is saved on purpose, and `html[draftmode="true"]` indicates why the page is not marked saved.

Put `#status` in chrome, or load the `indicator` plugin for a ready-made chip. With `clay-ui.js` loaded, save errors and offline states become persistent toasts. Without the `indicator` plugin, `clay-ui.js` also toasts "Saved" after every save; with it, set `clay.saveToast = true` to get those toasts too.

Events, all on `document`:

| Event | When | `detail` |
|---|---|---|
| `clay:ready` | boot finished | `{ clay }` |
| `clay:baseline-settled` | the load-settle baseline was taken | none |
| `clay:save-saving` | a save has been in flight 500 ms | `{ msg, msgType, timestamp }` (`msgType` is `""`) |
| `clay:save-saved` | the host accepted | `{ msg, msgType, timestamp, etag, captureId }` |
| `clay:save-error` | refused, or a network failure while online | `{ msg, msgType, timestamp, captureId }` (`msgType` is `""` in 1.8.2; the event name is the type) |
| `clay:save-offline` | failed while the browser is offline | `{ msg, msgType, timestamp, captureId }` (`msgType` is `""`) |
| `clay:save-conflict` | 412 | adds `changedBy`, `afterTimeout`, `etag`, `captureId` |
| `clay:save-conflict-resolved` | sync merged the version that caused the 412 | `{ timestamp }` |
| `clay:dirty`, `clay:clean` | unsaved-work state changed | none |
| `clay:unsaved-state-changed` | a registered state called `changed()` | `{ pending }` |

`clay:dirty` means "maybe dirty": typing a letter and deleting it leaves it raised until the next check.

`etag` and `captureId` on these events are additive. `clay:save-saved` carries the accepted `etag` (or `null`), and every save result event carries the `captureId` naming the bytes captured before the save was sent.

A save writes only the document's own bytes, so a downloaded HTML file links to its images rather than embedding them. When the file opens, the browser fetches uploaded images from the host's URLs. Opening a copy where those URLs do not resolve shows broken images.

## Recovery copies

`clay.recovery` keeps a local copy of edits the host has not accepted. It is not a save, and ClayJS never renders a page directly from it. After a reload, ClayJS compares the stored copies with the loaded file and asks before restoring one. A document is covered only for the channels its adapters register, so a page that registers no channel keeps no local copy.

A recovery copy is cleared by one save's own capture, not by the page's later state:

- Before sending a save, ClayJS hands the recovery service the exact bytes it captured and that capture's id. Each channel's `readSaved` reads those bytes once, and a copy is covered only when the payload it staged matches what the capture holds.
- A save that was skipped, refused, conflicted, errored, or left `unknown` clears nothing. A confirmed save clears only the revision its bytes covered, so typing during the request stays available.
- A newer revision from the same tab and every other tab's copy survive that clear. Any write that arrives after its save was acknowledged is refused by the stored watermark.
- **`clay:snapshot-ready` is not this proof.** It fires on a clone taken before `onbeforesave` hooks, document transforms, and final serialization, so what it shows can differ from the bytes the host accepts. Compare against `captureId` on `clay:save-saved` instead.

```js
document.addEventListener("clay:save-saved", (event) => {
  if (!event.detail.captureId) return;
  console.log("this save covered capture", event.detail.captureId, "etag", event.detail.etag);
});
```

The store lives in IndexedDB (`clay-recovery`), keeps 32 MiB per document scope and 64 MiB per database on this origin, holds plain text that any script on this origin can read, and reports `durable: false` whenever a write did not complete. It runs in edit mode only, draft mode included, and a reader never opens it. See [Recovery](https://clayjs.com/docs/recovery/) for the receipts, failure codes, and the difference between journaled, acknowledged, and checkpointed states.
