@panphora/clayjs 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/save.js CHANGED
@@ -19,13 +19,26 @@ import {
19
19
  addDocumentTransform,
20
20
  isSaveInProgress
21
21
  } from "./save-core.js";
22
- import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
22
+ import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
23
23
  import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
24
+ import { ROOT_LIBRARY_ATTRS } from "../lib/root-attrs.js";
24
25
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
26
+ import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
25
27
 
26
- // Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
28
+ // Keep this library's own root state out of the saved bytes.
29
+ //
30
+ // This used to write `savestatus="saved"` onto the clone instead of removing it,
31
+ // which stopped a mid-save "saving" from being baked in but still put a library
32
+ // attribute on disk. It is there today in four LOCAL_APPS documents. These three
33
+ // are this tab's UI truth, re-stamped on every load by edit-mode and by the save
34
+ // lane itself, so a stored copy is at best noise and at worst a lie: a file whose
35
+ // disk bytes say `savestatus="saved"` reads as saved before clayjs has booted.
36
+ //
37
+ // Scoped to the ROOT on purpose. `savestatus` on any other element is an authored
38
+ // attribute that `option:savestatus` reads, and stripping those would delete page
39
+ // content. Both clones get this, so the dirty comparison sees no difference.
27
40
  addDocumentTransform(clone => {
28
- clone.setAttribute('savestatus', 'saved');
41
+ for (const name of ROOT_LIBRARY_ATTRS) clone.removeAttribute(name);
29
42
  });
30
43
 
31
44
  // ============================================
@@ -87,9 +100,18 @@ window.addEventListener('offline', () => {
87
100
  });
88
101
 
89
102
  window.addEventListener('online', () => {
90
- if (document.documentElement.getAttribute('savestatus') === 'offline') {
91
- savePage();
92
- }
103
+ if (document.documentElement.getAttribute('savestatus') !== 'offline') return;
104
+ savePage().then((result) => {
105
+ // The page went offline with everything already saved, so there is nothing
106
+ // to send and the offline chip is simply stale. Clear it directly rather
107
+ // than through setSaveState: no save happened, and dispatching
108
+ // clay:save-saved would run every [onaftersave] handler and light every
109
+ // status chip as though one had.
110
+ if (result.msgType === 'skipped' &&
111
+ document.documentElement.getAttribute('savestatus') === 'offline') {
112
+ document.documentElement.setAttribute('savestatus', 'saved');
113
+ }
114
+ });
93
115
  });
94
116
 
95
117
  // ============================================
@@ -104,16 +126,36 @@ window.addEventListener('online', () => {
104
126
  // ever sending them: the edit was gone, with no error, no dirty flag, and no
105
127
  // close-tab warning.
106
128
  //
107
- // Post-save mutators are made invisible to the comparison instead — they mark what
108
- // they touch `no-trigger-autosave`, which strips it from every comparison capture
109
- // (see cache-bust.js and refetch-on-save.js). That leaves the baseline free to stay
110
- // exactly what savePage sent, which is the only value that is true by construction.
129
+ // Post-save mutators are made invisible to the comparison instead. They remember
130
+ // what the URL was authored as and restore it on every snapshot clone, so the live
131
+ // DOM carries the busted URL and the file carries the authored one (authored-url.js,
132
+ // used by cache-bust.js and refetch-on-save.js). That leaves the baseline free to
133
+ // stay exactly what savePage sent, which is the only value true by construction.
134
+ //
135
+ // They used to mark what they touched `no-trigger-autosave` instead. That hid the
136
+ // rewrite from the comparison only once the marker had reached the baseline, so the
137
+ // FIRST save on any page using either helper was followed by a spurious dirty state
138
+ // and a false close warning. It self-healed on the next save, which is why it went
139
+ // unnoticed.
111
140
 
112
141
  // Re-export from core for backward compatibility
113
142
  export { addDocumentTransform, getPageContents };
114
143
 
115
144
  let unsavedChanges = false;
145
+ // TWO SAVED BASELINES, one per comparison domain.
146
+ //
147
+ // lastSavedContents — the AUTOSAVE domain (no-trigger-autosave stripped).
148
+ // Answers "should this edit start a save on its own?" Autosave, the load
149
+ // settle guard, the live-sync baseline and the scoped-sync merge oracle all
150
+ // read this one, and none of them changes.
151
+ // lastSavedDirty — the DIRTY domain (no-trigger-autosave kept). Answers "is
152
+ // there anything here the person would lose?" Only an explicit savePage()
153
+ // and the close warning read it.
154
+ //
155
+ // On a page with no batching region the two are byte-identical, so nothing about
156
+ // the split is observable there.
116
157
  let lastSavedContents = '';
158
+ let lastSavedDirty = '';
117
159
  // A save was requested while one was on the wire; run one more when it settles.
118
160
  let pendingSave = false;
119
161
 
@@ -161,9 +203,12 @@ function skipped_(msg) {
161
203
  * where the write may or may not have landed, so it is not treated as success
162
204
  * either.
163
205
  */
164
- function applySaveResult(result, forComparison, label, gateToken) {
206
+ function applySaveResult(result, forComparison, forDirty, label, gateToken) {
165
207
  if (result.ok) {
208
+ // Both baselines advance from the SAME pre-request capture, never from the
209
+ // live DOM, so an edit made while the request was on the wire stays unsaved.
166
210
  lastSavedContents = forComparison;
211
+ lastSavedDirty = forDirty;
167
212
  unsavedChanges = false;
168
213
  // Generation-checked: clears the scoped-sync dirty gate only if nothing
169
214
  // changed while this save was on the wire.
@@ -194,7 +239,28 @@ function drainPendingSave() {
194
239
  export function getUnsavedChanges() { return unsavedChanges; }
195
240
  export function setUnsavedChanges(val) { unsavedChanges = val; }
196
241
  export function getLastSavedContents() { return lastSavedContents; }
197
- export function setLastSavedContents(val) { lastSavedContents = val; }
242
+ export function getLastSavedDirty() { return lastSavedDirty; }
243
+
244
+ /**
245
+ * Install both saved baselines from one post-morph capture.
246
+ *
247
+ * live-sync calls this after applying a frame it verified clean, where the page
248
+ * now IS the file on disk. Setting only the autosave baseline would leave the
249
+ * dirty baseline describing the pre-frame page, and the close warning would then
250
+ * warn about the frame's own content.
251
+ */
252
+ export function setLastSavedBaselines(forComparison, forDirty) {
253
+ lastSavedContents = forComparison;
254
+ lastSavedDirty = forDirty;
255
+ }
256
+
257
+ // Kept for back-compat: a caller with one string means the page has no batching
258
+ // region, or it does not know about the split. Setting both from it is the safe
259
+ // reading, since the domains coincide exactly when there is no such region.
260
+ export function setLastSavedContents(val) {
261
+ lastSavedContents = val;
262
+ lastSavedDirty = val;
263
+ }
198
264
 
199
265
  /**
200
266
  * Save the current page with change detection and state management.
@@ -209,6 +275,7 @@ export function setLastSavedContents(val) { lastSavedContents = val; }
209
275
  export function savePage(callback = () => {}) {
210
276
  return new Promise((resolve) => {
211
277
  if (!isEditMode && !window.clay?.testMode) {
278
+ clearExplicitSave();
212
279
  const skipped = skipped_('Not in edit mode');
213
280
  callback(skipped);
214
281
  return resolve(skipped);
@@ -218,6 +285,8 @@ export function savePage(callback = () => {}) {
218
285
  // than dropping it: the in-flight request carries the older bytes, and if no
219
286
  // further mutation happens to retrigger autosave, the newer ones would never
220
287
  // reach disk at all.
288
+ // Not cleared here: pendingSave means this request is deferred, not
289
+ // abandoned, and drainPendingSave is what eventually sends it.
221
290
  if (isSaveInProgress()) {
222
291
  pendingSave = true;
223
292
  const skipped = skipped_('Save already in progress');
@@ -235,12 +304,13 @@ export function savePage(callback = () => {}) {
235
304
  // Single capture: clone once, get both versions
236
305
  // forSave strips non-persisted regions ([no-save]/[save-remove])
237
306
  // forComparison additionally strips every autosave-off region
238
- let forSave, forComparison, snapshotHtml;
307
+ let forSave, forComparison, forDirty;
239
308
  const gateToken = gateCaptureToken();
240
309
  try {
241
- ({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
310
+ ({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
242
311
  } catch (err) {
243
312
  console.error('savePage: captureForSaveAndComparison failed', err);
313
+ clearExplicitSave();
244
314
  setSaveState('error', err.message);
245
315
  const result = { msg: err.message, msgType: 'error', code: null, etag: null };
246
316
  if (typeof callback === 'function') {
@@ -249,12 +319,18 @@ export function savePage(callback = () => {}) {
249
319
  return resolve(result);
250
320
  }
251
321
 
252
- // Compare directly - lastSavedContents is already stripped
253
- unsavedChanges = (forComparison !== lastSavedContents);
322
+ // An explicit save asks the DIRTY question: write anything the person would
323
+ // otherwise lose, including an edit inside a batching region that was never
324
+ // going to autosave itself.
325
+ unsavedChanges = (forDirty !== lastSavedDirty);
254
326
  logSaveCheck('savePage dirty check', !unsavedChanges);
255
327
 
256
- // Skip if content hasn't changed
328
+ // Skip if content hasn't changed. Clearing the explicit intent here is the
329
+ // whole point of scoping it: pressing Save on a clean page sends nothing, and
330
+ // leaving "a human asked for this" armed would hand it to the next
331
+ // background write and hide exactly the clobber the guard watches for.
257
332
  if (!unsavedChanges) {
333
+ clearExplicitSave();
258
334
  gateClearIfUnchanged(gateToken);
259
335
  const skipped = skipped_('No changes to save');
260
336
  callback(skipped);
@@ -266,13 +342,13 @@ export function savePage(callback = () => {}) {
266
342
 
267
343
  // Use saveHtml directly with our pre-captured content (avoids double capture)
268
344
  saveHtml(forSave, (result) => {
269
- applySaveResult(result, forComparison, 'updated after save', gateToken);
345
+ applySaveResult(result, forComparison, forDirty, 'updated after save', gateToken);
270
346
  if (typeof callback === 'function') {
271
347
  callback(result);
272
348
  }
273
349
  resolve(result);
274
350
  drainPendingSave();
275
- }, { snapshotHtml });
351
+ });
276
352
  });
277
353
  }
278
354
 
@@ -302,10 +378,10 @@ export function savePageForce(callback = () => {}) {
302
378
  setOfflineStateQuiet();
303
379
  }
304
380
 
305
- let forSave, forComparison, snapshotHtml;
381
+ let forSave, forComparison, forDirty;
306
382
  const gateToken = gateCaptureToken();
307
383
  try {
308
- ({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
384
+ ({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
309
385
  } catch (err) {
310
386
  console.error('savePageForce: captureForSaveAndComparison failed', err);
311
387
  setSaveState('error', err.message);
@@ -319,13 +395,13 @@ export function savePageForce(callback = () => {}) {
319
395
  setSavingState();
320
396
 
321
397
  saveHtml(forSave, (result) => {
322
- applySaveResult(result, forComparison, 'updated after force save', gateToken);
398
+ applySaveResult(result, forComparison, forDirty, 'updated after force save', gateToken);
323
399
  if (typeof callback === 'function') {
324
400
  callback(result);
325
401
  }
326
402
  resolve(result);
327
403
  drainPendingSave();
328
- }, { snapshotHtml });
404
+ });
329
405
  });
330
406
  }
331
407
 
@@ -391,8 +467,9 @@ function initBaselineCapture() {
391
467
  // Take immediate snapshot and set as baseline right away
392
468
  // This ensures saves during settle window work correctly
393
469
  // Store stripped version so comparisons are direct (no parsing needed)
394
- const immediateContents = captureForComparison();
470
+ const { forComparison: immediateContents, forDirty: immediateDirty } = captureForComparisonAndDirty();
395
471
  lastSavedContents = immediateContents;
472
+ lastSavedDirty = immediateDirty;
396
473
  baselineContents = immediateContents;
397
474
  logBaseline('immediate capture', `${immediateContents.length} chars`);
398
475
 
@@ -417,13 +494,18 @@ function initBaselineCapture() {
417
494
  if (unsubscribeMutation) unsubscribeMutation();
418
495
  userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
419
496
 
420
- // Only update if no user edits AND no saves occurred during settle
421
- // (if a save happened, lastSavedContents would differ from immediateContents)
422
- if (!userEdited && lastSavedContents === immediateContents) {
497
+ // Only update if no user edits AND no saves occurred during settle.
498
+ // BOTH baselines have to be untouched, not just the autosave one: a save
499
+ // whose only change was inside a batching region advances lastSavedDirty
500
+ // while leaving lastSavedContents byte-identical to the immediate capture.
501
+ // Checking one would let a second, unsent edit in that region be captured
502
+ // here as though it had been saved.
503
+ if (!userEdited && lastSavedContents === immediateContents && lastSavedDirty === immediateDirty) {
423
504
  // Store stripped version so comparisons are direct (no parsing needed)
424
505
  const gateToken = gateCaptureToken();
425
- const contents = captureForComparison();
506
+ const { forComparison: contents, forDirty: contentsDirty } = captureForComparisonAndDirty();
426
507
  lastSavedContents = contents;
508
+ lastSavedDirty = contentsDirty;
427
509
  baselineContents = contents;
428
510
  // Boot churn (modules rewriting attributes at DOM-ready) counted toward
429
511
  // the scoped-sync gate; the settled baseline is the proof it wasn't edits.
@@ -535,6 +617,9 @@ export function initSaveKeyboardShortcut() {
535
617
  let metaKeyPressed = isMac ? event.metaKey : event.ctrlKey;
536
618
  if (metaKeyPressed && event.keyCode == 83) {
537
619
  event.preventDefault();
620
+ // isTrusted, so a script calling dispatchEvent cannot manufacture human
621
+ // provenance for a write the person never asked for.
622
+ if (event.isTrusted) markExplicitSave();
538
623
  savePage();
539
624
  }
540
625
  });
@@ -547,6 +632,8 @@ export function initSaveKeyboardShortcut() {
547
632
  export function initHyperclaySaveButton() {
548
633
  document.addEventListener("click", event => {
549
634
  if (event.target.closest("[trigger-save]")) {
635
+ // el.click() is isTrusted:false, so this cannot be faked from script.
636
+ if (event.isTrusted) markExplicitSave();
550
637
  savePage();
551
638
  }
552
639
  });
@@ -559,6 +646,10 @@ export function initHyperclaySaveButton() {
559
646
  export function init() {
560
647
  if (!isEditMode) return;
561
648
 
649
+ // Every editable page, not just autosave pages. A manual-save page makes
650
+ // exactly the saves a person asked for, and used to report all of them as
651
+ // background writes because this was installed behind the autosave gate.
652
+ initUserGesture();
562
653
  initSaveKeyboardShortcut();
563
654
  initHyperclaySaveButton();
564
655
  }
@@ -37,8 +37,8 @@
37
37
  */
38
38
 
39
39
  import { stripExtensionNoise } from '../lib/extension-noise.js';
40
- import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
41
- import { saveTransport, DESKTOP_JSON } from './host-attrs.js';
40
+ import { restoreAuthoredUrls } from '../lib/authored-url.js';
41
+ import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, STRIP_FROM_DIRTY_CHECK, NO_TRIGGER_AUTOSAVE_SELECTOR, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
42
42
  import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
43
43
 
44
44
  // =============================================================================
@@ -135,6 +135,11 @@ export function captureSnapshot({ flushUndo = true } = {}) {
135
135
  hook(clone);
136
136
  }
137
137
 
138
+ // Put back any URL clay rewrote at runtime (cache-bust, refetch-on-save) so
139
+ // the file keeps what the page author wrote. Before onbeforesnapshot, so an
140
+ // authored handler sees the same URLs the file will.
141
+ restoreAuthoredUrls(clone);
142
+
138
143
  runAuthoredHandlers(clone, 'onbeforesnapshot');
139
144
 
140
145
  for (const el of clone.querySelectorAll(SNAPSHOT_REMOVE_SELECTOR)) {
@@ -203,6 +208,78 @@ export function captureForComparison({ flushUndo = true } = {}) {
203
208
  return "<!DOCTYPE html>" + clone.outerHTML;
204
209
  }
205
210
 
211
+ /**
212
+ * Capture in the DIRTY domain: like captureForComparison, but it KEEPS
213
+ * no-trigger-autosave regions.
214
+ *
215
+ * The two domains answer different questions. "Should this edit start an
216
+ * autosave?" is the autosave domain (captureForComparison). "Is there anything
217
+ * here the person would lose?" is this one — an edit inside a batching region is
218
+ * a real edit that an explicit save must write and a close must warn about.
219
+ *
220
+ * On a document with no such region this returns bytes identical to
221
+ * captureForComparison, which is what keeps the two baselines comparable.
222
+ *
223
+ * @returns {string}
224
+ */
225
+ export function captureForDirtyCheck({ flushUndo = true } = {}) {
226
+ const clone = captureSnapshot({ flushUndo });
227
+
228
+ runAuthoredHandlers(clone, 'onbeforesave');
229
+
230
+ for (const el of clone.querySelectorAll(STRIP_FROM_DIRTY_CHECK)) {
231
+ el.remove();
232
+ }
233
+
234
+ for (const hook of documentTransforms) {
235
+ hook(clone);
236
+ }
237
+
238
+ return "<!DOCTYPE html>" + clone.outerHTML;
239
+ }
240
+
241
+ /**
242
+ * Both comparison domains from ONE snapshot, with no save clone and no
243
+ * snapshot-ready event.
244
+ *
245
+ * For the callers that need to install or check both baselines without sending
246
+ * anything: the load-time baseline capture and live-sync's post-morph baseline
247
+ * setter. Taking two separate snapshots there would cost two full DOM clones per
248
+ * boot and per applied frame, for bytes that are usually identical.
249
+ *
250
+ * @returns {{ forComparison: string, forDirty: string }}
251
+ */
252
+ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
253
+ const clone = captureSnapshot({ flushUndo });
254
+
255
+ runAuthoredHandlers(clone, 'onbeforesave');
256
+
257
+ const dirtyClone = clone.querySelector(NO_TRIGGER_AUTOSAVE_SELECTOR)
258
+ ? clonePreventingOnclone(clone)
259
+ : null;
260
+
261
+ for (const el of clone.querySelectorAll(STRIP_FROM_COMPARISON)) {
262
+ el.remove();
263
+ }
264
+ for (const hook of documentTransforms) {
265
+ hook(clone);
266
+ }
267
+ const forComparison = "<!DOCTYPE html>" + clone.outerHTML;
268
+
269
+ let forDirty = forComparison;
270
+ if (dirtyClone) {
271
+ for (const el of dirtyClone.querySelectorAll(STRIP_FROM_DIRTY_CHECK)) {
272
+ el.remove();
273
+ }
274
+ for (const hook of documentTransforms) {
275
+ hook(dirtyClone);
276
+ }
277
+ forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
278
+ }
279
+
280
+ return { forComparison, forDirty };
281
+ }
282
+
206
283
  /**
207
284
  * Single-capture function for both saving and comparison.
208
285
  *
@@ -211,23 +288,11 @@ export function captureForComparison({ flushUndo = true } = {}) {
211
288
  *
212
289
  * @param {Object} options
213
290
  * @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
214
- * @returns {{ forSave: string, forComparison: string, snapshotHtml: ?string }}
291
+ * @returns {{ forSave: string, forComparison: string, forDirty: string }}
215
292
  */
216
293
  export function captureForSaveAndComparison({ emitForSync = true } = {}) {
217
294
  const clone = captureSnapshot();
218
295
 
219
- // The unstripped snapshot, for a host that asked for the desktop JSON envelope:
220
- // the save then sends both the stripped document and this. Returned to the caller
221
- // rather than parked on a window global, so it can only ever be paired with the
222
- // content captured alongside it. As a global it was cleared on success only, so
223
- // two captures without an intervening successful save shipped a stale snapshot
224
- // next to fresh content. Captured only when the document DECLARES the transport;
225
- // this used to key off `location.hostname`, which set it on every localhost page
226
- // whether or not its host wanted it.
227
- const snapshotHtml = saveTransport() === DESKTOP_JSON
228
- ? '<!DOCTYPE html>' + clone.outerHTML
229
- : null;
230
-
231
296
  // Emit for live-sync before any stripping
232
297
  if (emitForSync) {
233
298
  document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
@@ -241,6 +306,15 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
241
306
  // Clone for comparison before stripping (cheaper than cloning live DOM)
242
307
  const compareClone = clonePreventingOnclone(clone);
243
308
 
309
+ // The dirty domain differs from the autosave domain only inside
310
+ // no-trigger-autosave regions, so a document without one pays nothing: the two
311
+ // strip sets remove exactly the same nodes and one string serves both. The
312
+ // predicate reads the captured clone after authored handlers have run, and
313
+ // covers all three spellings via the policy's own selector.
314
+ const dirtyClone = clone.querySelector(NO_TRIGGER_AUTOSAVE_SELECTOR)
315
+ ? clonePreventingOnclone(clone)
316
+ : null;
317
+
244
318
  // Save clone: run hooks (freeze restore lives here), THEN strip [no-save]/[save-remove]
245
319
  // LAST (snapshot-algorithm step 7) so freeze-restored [no-save] content can't leak to disk.
246
320
  for (const hook of documentTransforms) {
@@ -260,21 +334,42 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
260
334
  }
261
335
  const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
262
336
 
263
- return { forSave, forComparison, snapshotHtml };
337
+ // Dirty clone: same shape as the compare clone, one selector weaker.
338
+ let forDirty = forComparison;
339
+ if (dirtyClone) {
340
+ for (const el of dirtyClone.querySelectorAll(STRIP_FROM_DIRTY_CHECK)) {
341
+ el.remove();
342
+ }
343
+ for (const hook of documentTransforms) {
344
+ hook(dirtyClone);
345
+ }
346
+ forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
347
+ }
348
+
349
+ return { forSave, forComparison, forDirty };
264
350
  }
265
351
 
266
352
  /**
267
- * Capture for a protected live-sync merge: the save-domain and comparison-
268
- * domain clones of ONE snapshot, plus a WeakMap pairing every comparison
269
- * element to its save-clone twin.
353
+ * Capture for a protected live-sync merge: the save-domain and loss-domain
354
+ * clones of ONE snapshot, plus a WeakMap pairing every loss-domain element to
355
+ * its save-clone twin.
270
356
  *
271
- * The pairing is recorded immediately after the comparison clone is created,
357
+ * The pairing is recorded immediately after the compare clone is created,
272
358
  * while the two trees are still isomorphic; each side's strips then remove
273
- * nodes independently without disturbing it. The scoped-sync dirty diff runs
274
- * on the comparison clone (the same domain as lastSavedContents), and dirty
275
- * roots map through pairMap to save-domain subtrees (the same domain as the
276
- * file on disk), which still carry the no-trigger-autosave / freeze / no-watch
277
- * children the comparison strips.
359
+ * nodes independently without disturbing it.
360
+ *
361
+ * The compare clone strips STRIP_FROM_DIRTY_CHECK, not STRIP_FROM_COMPARISON:
362
+ * the merge asks "would applying this frame destroy work?", which is the same
363
+ * question the close warning asks, so it must use the same domain. The autosave
364
+ * domain is the wrong one here because it strips no-trigger-autosave, and an
365
+ * unsaved edit in a batching region is exactly the work the merge exists to
366
+ * protect. Disposable churn stays out of both domains via no-dirty, which is
367
+ * what keeps a self-churning region from promoting a dirty root to <body> and
368
+ * holding every frame forever.
369
+ *
370
+ * Dirty roots map through pairMap to save-domain subtrees (the same domain as
371
+ * the file on disk), which still carry the freeze / no-watch children the
372
+ * compare clone strips.
278
373
  *
279
374
  * Never emits snapshot-ready (this capture must not feed the send pipeline)
280
375
  * and never flushes the undo batch (it runs per incoming frame, not per save).
@@ -305,7 +400,7 @@ export function captureForMerge() {
305
400
  el.remove();
306
401
  }
307
402
 
308
- for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
403
+ for (const el of compareClone.querySelectorAll(STRIP_FROM_DIRTY_CHECK)) {
309
404
  el.remove();
310
405
  }
311
406
  for (const hook of documentTransforms) {
@@ -7,15 +7,17 @@
7
7
  * Works independently of autosave - no mutation observer needed during editing,
8
8
  * just a single comparison when the user tries to leave.
9
9
  *
10
- * Both current and stored content have [save-remove] and [save-ignore] stripped,
11
- * so comparison is direct with no parsing needed.
10
+ * Both current and stored content have [save-remove] stripped, so comparison is
11
+ * direct with no parsing needed. [save-ignore] / no-trigger-autosave regions are
12
+ * KEPT here: they don't trigger an autosave, so an edit in one is precisely the
13
+ * kind that would be lost without a warning.
12
14
  *
13
15
  * Requires the 'save-system' module (automatically included as dependency).
14
16
  */
15
17
 
16
18
  import { isEditMode } from "./is-edit-mode.js";
17
- import { captureForComparison } from "./snapshot.js";
18
- import { getLastSavedContents } from "./save.js";
19
+ import { captureForDirtyCheck } from "./snapshot.js";
20
+ import { getLastSavedDirty } from "./save.js";
19
21
  import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
20
22
 
21
23
  // Pre-load diff library if debug mode is on (so it's ready for unload)
@@ -30,9 +32,11 @@ preloadIfEnabled();
30
32
  window.addEventListener('beforeunload', (event) => {
31
33
  if (!isEditMode) return;
32
34
 
33
- // Compare directly - both are already stripped
34
- const currentForCompare = captureForComparison();
35
- const lastSaved = getLastSavedContents();
35
+ // The DIRTY domain, not the autosave domain. An edit inside a
36
+ // no-trigger-autosave region never starts a save by itself, which is exactly
37
+ // why closing the tab on one has to warn: nothing else is going to write it.
38
+ const currentForCompare = captureForDirtyCheck();
39
+ const lastSaved = getLastSavedDirty();
36
40
 
37
41
  if (currentForCompare !== lastSaved) {
38
42
  // Debug: log what's different before showing the warning
@@ -12,18 +12,7 @@
12
12
 
13
13
  import { captureSnapshot, captureForSave, addDocumentTransform } from "../core/snapshot.js";
14
14
  import { saveHtml, replacePageWith, isSaveInProgress } from "../core/save-core.js";
15
- import {
16
- addRegionToken,
17
- resolveRegionPolicy,
18
- isInert,
19
- isSnapshotRemoved,
20
- PERSIST,
21
- REGION_ATTRS,
22
- STRIP_FROM_SAVE,
23
- STRIP_FROM_COMPARISON,
24
- SNAPSHOT_REMOVE_SELECTOR,
25
- FREEZE_SELECTOR,
26
- } from "../lib/region-policy.js";
15
+ import { regionShape } from "../lib/region-policy.js";
27
16
 
28
17
  const clay = (window.clay = window.clay || {});
29
18
 
@@ -40,20 +29,8 @@ clay.internals = {
40
29
 
41
30
  // Write your own attribute without hardcoding our selectors. Doing it by hand is
42
31
  // how a custom attribute quietly stops respecting [no-save] two releases later.
43
- region: {
44
- addRegionToken,
45
- resolveRegionPolicy,
46
- isInert,
47
- isSnapshotRemoved,
48
- PERSIST,
49
- REGION_ATTRS,
50
- selectors: {
51
- stripFromSave: STRIP_FROM_SAVE,
52
- stripFromComparison: STRIP_FROM_COMPARISON,
53
- snapshotRemove: SNAPSHOT_REMOVE_SELECTOR,
54
- freeze: FREEZE_SELECTOR,
55
- },
56
- },
32
+ // The same object clay.region holds, so the two can no longer drift.
33
+ region: regionShape,
57
34
 
58
35
  // The save lane under clay.save. saveHtml sends bytes you supply, so it bypasses
59
36
  // the snapshot pipeline entirely: whatever you hand it is what lands in the