clay.js
  1. ClayJS
  2. How ClayJS works
  3. Saving

Saving

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

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:

    JavaScript
    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:

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

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), the result has msgType: "error" and no ok field.

ResultMeaning
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)); 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.

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:

JavaScript
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 }):

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

Conflicts on save (412)#

On hosts that announce conditional (all three hosts in 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.

HookRuns onUse for
onbeforesnapshot="code" attributeThe clone, for every snapshot: save, change check, sync broadcastNormalizing tab-local state out of anything that leaves the tab
onbeforesave="code" attributeThe 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 checkWriting derived values into the saved copy only
onaftersave="code" attributeThe live element after each accepted savePost-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:

JavaScript
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:

EventWhendetail
clay:readyboot finished{ clay }
clay:baseline-settledthe load-settle baseline was takennone
clay:save-savinga save has been in flight 500 ms{ msg, msgType, timestamp } (msgType is "")
clay:save-savedthe host accepted{ msg, msgType, timestamp, etag, captureId }
clay:save-errorrefused, or a network failure while online{ msg, msgType, timestamp, captureId } (msgType is "" in 1.8.2; the event name is the type)
clay:save-offlinefailed while the browser is offline{ msg, msgType, timestamp, captureId } (msgType is "")
clay:save-conflict412adds changedBy, afterTimeout, etag, captureId
clay:save-conflict-resolvedsync merged the version that caused the 412{ timestamp }
clay:dirty, clay:cleanunsaved-work state changednone
clay:unsaved-state-changeda 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.
JavaScript
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 for the receipts, failure codes, and the difference between journaled, acknowledged, and checkpointed states.

Part of the ClayJS complete guideDownload the whole guide as Markdown