# 4. What gets saved

> What a save writes, which state persists, and the attributes that keep things out of the file.

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

## What a save writes

A save writes the current DOM with these changes:

- Subtrees marked `no-save`, `no-snapshot`, or `editor-ui` are removed.
- `freeze` elements write the inner HTML they had when they first appeared (at load or on insertion). Their live contents are a per-tab scratchpad.
- Mode attributes go inert ([Mode attributes: one file for editors and viewers](https://clayjs.com/docs/guide/edit-mode/#mode-attributes-one-file-for-editors-and-viewers)). Root attributes `editmode`, `pageowner`, `savestatus`, `savetoken`, and `documentetag` are removed.
- `persist` controls write their live values ([`persist`: form controls](https://clayjs.com/docs/guide/record/#persist-form-controls)).
- Runtime residue from ClayJS editors (richclay attributes, toolbars) and browser extensions (password managers, Grammarly, Dark Reader) is stripped.
- Your hooks run ([Hooks](https://clayjs.com/docs/guide/saving/#hooks)).

Everything else on the page is written as it is, including changes builders forget:

- Classes and attributes toggled for UI: `.selected`, `aria-expanded`, `<details open>`, `hidden`. Mark them `ephemeral` instead ([Ephemeral state: per-tab state on saved elements](https://clayjs.com/docs/guide/record/#ephemeral-state-per-tab-state-on-saved-elements)). `<dialog open>` is the exception: it is never saved.
- Inline styles from libraries and helpers: Sortable's `draggable="false"` and `style=""`, `el.hide()`, `All(...).css()`, and `body.style.overflow = "hidden"` from an open modal or crop dialog.
- Any element a script inserts outside an excluded region.

State outside the light DOM and attributes is never saved. This includes JavaScript objects, `localStorage`, canvas pixels, scroll position, focus, and selection. It also excludes shadow DOM content (only the host element's light DOM and attributes are saved), iframe content, the live value of a control without `persist`, and password, hidden, and file input values even with `persist`.

## Regions: the `clay` attribute

Region tokens on the `clay` attribute control how ClayJS saves, syncs, watches, and undoes an element and its descendants. Pass space-separated tokens in `clay="token token"` or use bare attributes like `<div no-save>`. Set tokens on creation or with `clay.region.addRegionToken(el, "editor-ui")`.

| Token | Saved file | Sent to peers | Starts autosave | Counts as unsaved | In undo | Use for |
|---|---|---|---|---|---|---|
| (none) | current | yes | yes | yes | yes | the record |
| `editor-ui` | removed | no | no | no | no | **UI a script fills or changes at runtime, and tab-only derived values** ([Chrome](https://clayjs.com/docs/guide/architecture/#chrome)) |
| `no-save` | removed | carried in the bytes, never applied | no | no | yes | rarely; prefer `editor-ui` |
| `no-snapshot` | removed | no (each tab keeps its own) | no | no | yes | part of `editor-ui` |
| `freeze` | element current, contents as when it appeared | element yes, contents never | no | no | yes | a scratchpad: per-tab generated content in a saved element ([Derived values](https://clayjs.com/docs/guide/architecture/#derived-values)) |
| `no-dirty` | current | yes, **remote wins** | no | no | yes | derived values the saved file must show ([Derived values](https://clayjs.com/docs/guide/architecture/#derived-values)) |
| `no-trigger-autosave` | current | yes | no | **yes** | yes | a heavy editor whose saves you batch yourself ([Manual-save pages and batching](https://clayjs.com/docs/guide/saving/#manual-save-pages-and-batching)) |
| `no-watch` | current | yes, remote wins | no | no | no | rarely: high-frequency content that is disposable |
| `no-undo` | current | yes | yes | yes | no | content with its own undo (richclay sets it at runtime) |
| `no-data` | current | yes | yes | yes | yes | content the data API and `extractData` must skip |
| `live` | the store's current value, at checkpoints | never through the merge; the `live` plugin carries it ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)) | no | no | no | a `clay="live"` JSON tag |

**When chrome needs a region, use `editor-ui`, not `no-save`.** `editor-ui` bundles `no-data no-save no-snapshot no-watch no-undo`. By itself, `no-save` still records undo steps and stays in the snapshot live sync sends to peers. Peers never apply it, but its text travels to every edit-mode tab (viewers receive the saved file without it). `editor-ui` replaces `clay="no-save no-watch"` from older docs.

Legacy spellings still work: `save-remove` is `no-save no-undo`, `save-ignore` is `no-dirty no-undo`, `save-freeze` is `freeze no-undo`, `mutations-ignore` is `no-watch`, `snapshot-remove` is `no-snapshot`.

Traps in this table:

- **An edit inside `no-watch` or `no-dirty` is invisible to `clay.save()` and the close warning.** `save()` answers "No changes to save" and closing the tab loses it silently. It is saved only when another change triggers a save, or by `clay.save.force()`. Never put typed work there.
- **`freeze` is a scratchpad.** The element belongs to the document: its attributes save, and it syncs, moves, and merges like any element. Its contents belong to this tab: a runtime change inside is never saved, not even by `clay.save.force()`, and never reaches a peer. Every save restores the contents the element had when it first appeared.
- **A scratchpad from a peer arrives empty.** When a peer adds a record that contains a frozen element, it is inserted without contents. Fill scratchpads from code that runs on every change (a sap `effect` or your render function), never once at boot.
- **Fill a scratchpad after `clay.ready`, and insert new ones empty.** The contents are captured when the element first appears. Content written before boot, or present in a cloned element when inserted, becomes its saved contents. Empty the scratchpads in a copied record before inserting it.
- **Create `editor-ui` roots at runtime from an unmarked `<template>` on every load.** An `editor-ui` root disappears from the saved file, so it cannot be authored in the file. Do not mark the blueprint inside the `<template>` as `editor-ui`, or the first save empties the template and leaves no chrome to clone on the next load. Leave the blueprint unmarked and set `clay="editor-ui"` on each clone as you insert it.

## `persist`: form controls

Put `persist` on every `<input>`, `<textarea>`, and `<select>` whose value belongs to the record. A control's typed value is a property, not an attribute, and serialization only reads attributes. Without `persist`, a typed value is lost on save and the file keeps the authored `value`.

```html
<input type="checkbox" persist viewmode:disabled disabled checked>
<input type="number" step="0.01" persist value="312.40">
<textarea persist></textarea>
<select persist><option>todo</option><option selected>doing</option></select>
```

- On `input` (text-like fields) and `change` (checkbox, radio, select), ClayJS mirrors the live value into `value`, `checked`, or `selected`. A textarea's value goes into a `data-value` attribute while typing and becomes its text content in the save.
- A snapshot also copies every `persist` control's live state, so a value set from script with no event is saved too.
- Password, hidden, and file inputs are never persisted. An authored `value` on them stays in the file.
- `persist` goes on the control itself, never on a wrapper.
- Changing a `persist` control's value counts as an edit and starts autosave.
- Do not persist a filter or search box. Those are tab-local: put them in chrome ([Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state)).

**After undo, redo, or a merge, ClayJS puts each control's display back in step with its attributes.** A control the user has touched shows its live property, not its attribute. A merge and the undo plugin copy `checked`, `value`, and `selected` back onto the property (undo does this for `persist` controls outside `no-undo` regions). A merge leaves the focused text field alone, while undo restores it too.

## Where to keep data that is not visible text

In order of preference:

1. **Attributes on the element they describe.** `<li class="card" data-id="c-7f3a" data-due="2026-11-02" data-points="3">`. The default. Saved, merged per attribute, readable by agents, and moves with the element.
2. **Hidden elements inside the record.** `<ul hidden class="archive">`, or the card detail inside the card ([Detail views: the record is the modal](https://clayjs.com/docs/guide/architecture/#detail-views-the-record-is-the-modal)). Same properties, good for archived items and detail fields.
3. **A JSON script with `merge`.** For data that markup expresses badly (formula grids, chart series, settings objects):

   ```html
   <script type="application/json" id="model" merge="store">{"series":[{"id":"s1","points":[3,5,8]}]}</script>
   ```

   ```js
   function writeModel(model) {
     document.getElementById("model").textContent = JSON.stringify(model).replace(/</g, "\\u003c");
   }
   ```

   Always escape `<` so a value containing `</script>` cannot close the element early and corrupt the file on reload. Re-parse on every `clay:sync-applied`, because a merge changes the text, not your parsed object. When two writers change the same key, the incoming value wins and the lost value shows in the conflict notice ([Regions and JSON under sync](https://clayjs.com/docs/guide/live-sync/#regions-and-json-under-sync)).
4. **A live JSON script (`clay="live"`)** for state that changes faster than a save: game boards, scores, cursors ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)).
5. **`<template>` content: never for mutable data.** It is saved, but edits there never trigger autosave.

## Element identity: `data-id` and `id`

Live sync and undo pair elements across versions by **`data-id`, then `id`**, used only when the value is unique on its side and the tag names match. Without an id, elements pair by content, which works for distinct text and fails for identical new items.

- **Give every repeated item a unique `data-id` when it is created.** With ids, two people adding an item at the same place always keep both items. Without ids, items with distinct text usually both land, items with similar text ("New card", "New task") drop one into the conflict ledger, and items with identical or blank text drop one **with no ledger record**.
- **Assign fresh ids when cloning.** A duplicated `data-id` counts as no id at all. Re-id the clone and every id-carrying descendant, as the writer's duplicate command does:

  ```js
  const copy = block.cloneNode(true);
  copy.querySelectorAll('[clay~="editor-ui"]').forEach((el) => el.remove());
  copy.querySelectorAll("[data-richclay-active]").forEach((el) => clay.RichClay.stripElement(el));
  copy.dataset.id = newId("b");
  copy.querySelectorAll("li[data-id]").forEach((li) => { li.dataset.id = newId("t"); });
  ```

- **Leave `data-id` off templates.** The command that clones a template assigns one. For a plain "add an item" button, `<button add-from="#item-tpl" add-to="#list">` handles the clone, the id, and the focus with no script.
- **Never stamp ids on open.** Opening an old file must not rewrite it. Add an id when an item is created or first changed.
- **Make ids without `crypto.randomUUID`** unless the file is only ever served over https or localhost, because it is missing in insecure contexts. The examples use `prefix + "-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)`.
- **Use ids to find records in commands:** `` board.querySelector(`.card[data-id="${CSS.escape(id)}"]`) ``. Never find a record by position.

## Assets

ClayJS saves an image through its `src`. Upload the file and write the returned URL ([upload and quickcrop](https://clayjs.com/docs/guide/components/#upload-and-quickcrop)). Every host returns a host path such as `/_/uploads/assets-<name>/photo-ab12.png`, which keeps working on that host when the document is renamed or moved. A document opened straight from disk shows those images broken; Export as zip takes a document with its files. Where the host cannot store files, embed a `data:` URL. Data URLs work anywhere, but every save carries the bytes, so keep embedded images small.

## Ephemeral state: per-tab state on saved elements

Use `ephemeral` for per-tab state on a saved element, such as a selected card, a row being dragged, or an open section. Toggling a normal class or attribute is an edit like any other: it marks the page unsaved, starts an autosave, adds an undo step, reaches every peer, and lands in the file. Mark it `ephemeral` instead:

```html
<li class="card ephemeral-selected:ring-2" data-id="c-7f3a">…</li>
<details ephemeral:open><summary>Notes</summary>…</details>
```

```js
clay.ephemeral.toggle(card, "selected");   // add, remove, toggle(el, word, force), has
```

- **`ephemeral="selected dragging"`** holds words a script sets for this tab. Style it with `[ephemeral~="selected"]`, or with the Tailwind variant `ephemeral-selected:` (`@plugin "@panphora/clayjs/tailwind";`; Hyperclay's on-save Tailwind has it built in).
- **`ephemeral:NAME`** makes the element's own `NAME` attribute tab-only: `<details ephemeral:open>`, `<button ephemeral:aria-expanded>`. The marker is saved; the attribute it names is removed from the saved file. Tailwind's own `open:` and `aria-expanded:` variants style these.
- **`open` on `<dialog>`** is tab-only with no marker: `showModal()` sets it, and a dialog saved open is always a bug.
- Ephemeral state is never saved, sent to peers, undone, or counted as a change. An incoming merge from a peer or from the file on disk never writes or removes it on an element this tab already has. The router's `route-active` gets the same treatment.
- Identity (`class`, `id`, `data-id`), region attributes (`clay`, `no-save`, `freeze`, and the rest), and form state (`value`, `checked`, `selected`, which `persist` owns) can never be made ephemeral.
- Because ephemeral changes are not changes, `onmutation` and `onglobalmutation` handlers do not fire for them. Update anything derived from a selection in the same function that changes it.

Use `ephemeral` for state on one element. State that comes from the URL hash and spans many records (the open card, a filter) is still painted with a runtime stylesheet ([Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state)).
