@panphora/clayjs 0.6.1 → 0.7.1

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,10 +19,11 @@ 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
24
  import { ROOT_LIBRARY_ATTRS } from "../lib/root-attrs.js";
25
25
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
26
+ import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
26
27
 
27
28
  // Keep this library's own root state out of the saved bytes.
28
29
  //
@@ -99,9 +100,18 @@ window.addEventListener('offline', () => {
99
100
  });
100
101
 
101
102
  window.addEventListener('online', () => {
102
- if (document.documentElement.getAttribute('savestatus') === 'offline') {
103
- savePage();
104
- }
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
+ });
105
115
  });
106
116
 
107
117
  // ============================================
@@ -116,16 +126,36 @@ window.addEventListener('online', () => {
116
126
  // ever sending them: the edit was gone, with no error, no dirty flag, and no
117
127
  // close-tab warning.
118
128
  //
119
- // Post-save mutators are made invisible to the comparison instead — they mark what
120
- // they touch `no-trigger-autosave`, which strips it from every comparison capture
121
- // (see cache-bust.js and refetch-on-save.js). That leaves the baseline free to stay
122
- // 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.
123
140
 
124
141
  // Re-export from core for backward compatibility
125
142
  export { addDocumentTransform, getPageContents };
126
143
 
127
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.
128
157
  let lastSavedContents = '';
158
+ let lastSavedDirty = '';
129
159
  // A save was requested while one was on the wire; run one more when it settles.
130
160
  let pendingSave = false;
131
161
 
@@ -173,9 +203,12 @@ function skipped_(msg) {
173
203
  * where the write may or may not have landed, so it is not treated as success
174
204
  * either.
175
205
  */
176
- function applySaveResult(result, forComparison, label, gateToken) {
206
+ function applySaveResult(result, forComparison, forDirty, label, gateToken) {
177
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.
178
210
  lastSavedContents = forComparison;
211
+ lastSavedDirty = forDirty;
179
212
  unsavedChanges = false;
180
213
  // Generation-checked: clears the scoped-sync dirty gate only if nothing
181
214
  // changed while this save was on the wire.
@@ -206,7 +239,28 @@ function drainPendingSave() {
206
239
  export function getUnsavedChanges() { return unsavedChanges; }
207
240
  export function setUnsavedChanges(val) { unsavedChanges = val; }
208
241
  export function getLastSavedContents() { return lastSavedContents; }
209
- 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
+ }
210
264
 
211
265
  /**
212
266
  * Save the current page with change detection and state management.
@@ -221,6 +275,7 @@ export function setLastSavedContents(val) { lastSavedContents = val; }
221
275
  export function savePage(callback = () => {}) {
222
276
  return new Promise((resolve) => {
223
277
  if (!isEditMode && !window.clay?.testMode) {
278
+ clearExplicitSave();
224
279
  const skipped = skipped_('Not in edit mode');
225
280
  callback(skipped);
226
281
  return resolve(skipped);
@@ -230,6 +285,8 @@ export function savePage(callback = () => {}) {
230
285
  // than dropping it: the in-flight request carries the older bytes, and if no
231
286
  // further mutation happens to retrigger autosave, the newer ones would never
232
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.
233
290
  if (isSaveInProgress()) {
234
291
  pendingSave = true;
235
292
  const skipped = skipped_('Save already in progress');
@@ -247,12 +304,13 @@ export function savePage(callback = () => {}) {
247
304
  // Single capture: clone once, get both versions
248
305
  // forSave strips non-persisted regions ([no-save]/[save-remove])
249
306
  // forComparison additionally strips every autosave-off region
250
- let forSave, forComparison, snapshotHtml;
307
+ let forSave, forComparison, forDirty;
251
308
  const gateToken = gateCaptureToken();
252
309
  try {
253
- ({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
310
+ ({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
254
311
  } catch (err) {
255
312
  console.error('savePage: captureForSaveAndComparison failed', err);
313
+ clearExplicitSave();
256
314
  setSaveState('error', err.message);
257
315
  const result = { msg: err.message, msgType: 'error', code: null, etag: null };
258
316
  if (typeof callback === 'function') {
@@ -261,12 +319,18 @@ export function savePage(callback = () => {}) {
261
319
  return resolve(result);
262
320
  }
263
321
 
264
- // Compare directly - lastSavedContents is already stripped
265
- 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);
266
326
  logSaveCheck('savePage dirty check', !unsavedChanges);
267
327
 
268
- // 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.
269
332
  if (!unsavedChanges) {
333
+ clearExplicitSave();
270
334
  gateClearIfUnchanged(gateToken);
271
335
  const skipped = skipped_('No changes to save');
272
336
  callback(skipped);
@@ -278,13 +342,13 @@ export function savePage(callback = () => {}) {
278
342
 
279
343
  // Use saveHtml directly with our pre-captured content (avoids double capture)
280
344
  saveHtml(forSave, (result) => {
281
- applySaveResult(result, forComparison, 'updated after save', gateToken);
345
+ applySaveResult(result, forComparison, forDirty, 'updated after save', gateToken);
282
346
  if (typeof callback === 'function') {
283
347
  callback(result);
284
348
  }
285
349
  resolve(result);
286
350
  drainPendingSave();
287
- }, { snapshotHtml });
351
+ });
288
352
  });
289
353
  }
290
354
 
@@ -314,10 +378,10 @@ export function savePageForce(callback = () => {}) {
314
378
  setOfflineStateQuiet();
315
379
  }
316
380
 
317
- let forSave, forComparison, snapshotHtml;
381
+ let forSave, forComparison, forDirty;
318
382
  const gateToken = gateCaptureToken();
319
383
  try {
320
- ({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
384
+ ({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
321
385
  } catch (err) {
322
386
  console.error('savePageForce: captureForSaveAndComparison failed', err);
323
387
  setSaveState('error', err.message);
@@ -331,13 +395,13 @@ export function savePageForce(callback = () => {}) {
331
395
  setSavingState();
332
396
 
333
397
  saveHtml(forSave, (result) => {
334
- applySaveResult(result, forComparison, 'updated after force save', gateToken);
398
+ applySaveResult(result, forComparison, forDirty, 'updated after force save', gateToken);
335
399
  if (typeof callback === 'function') {
336
400
  callback(result);
337
401
  }
338
402
  resolve(result);
339
403
  drainPendingSave();
340
- }, { snapshotHtml });
404
+ });
341
405
  });
342
406
  }
343
407
 
@@ -403,8 +467,9 @@ function initBaselineCapture() {
403
467
  // Take immediate snapshot and set as baseline right away
404
468
  // This ensures saves during settle window work correctly
405
469
  // Store stripped version so comparisons are direct (no parsing needed)
406
- const immediateContents = captureForComparison();
470
+ const { forComparison: immediateContents, forDirty: immediateDirty } = captureForComparisonAndDirty();
407
471
  lastSavedContents = immediateContents;
472
+ lastSavedDirty = immediateDirty;
408
473
  baselineContents = immediateContents;
409
474
  logBaseline('immediate capture', `${immediateContents.length} chars`);
410
475
 
@@ -429,13 +494,18 @@ function initBaselineCapture() {
429
494
  if (unsubscribeMutation) unsubscribeMutation();
430
495
  userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
431
496
 
432
- // Only update if no user edits AND no saves occurred during settle
433
- // (if a save happened, lastSavedContents would differ from immediateContents)
434
- 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) {
435
504
  // Store stripped version so comparisons are direct (no parsing needed)
436
505
  const gateToken = gateCaptureToken();
437
- const contents = captureForComparison();
506
+ const { forComparison: contents, forDirty: contentsDirty } = captureForComparisonAndDirty();
438
507
  lastSavedContents = contents;
508
+ lastSavedDirty = contentsDirty;
439
509
  baselineContents = contents;
440
510
  // Boot churn (modules rewriting attributes at DOM-ready) counted toward
441
511
  // the scoped-sync gate; the settled baseline is the proof it wasn't edits.
@@ -547,6 +617,9 @@ export function initSaveKeyboardShortcut() {
547
617
  let metaKeyPressed = isMac ? event.metaKey : event.ctrlKey;
548
618
  if (metaKeyPressed && event.keyCode == 83) {
549
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();
550
623
  savePage();
551
624
  }
552
625
  });
@@ -559,6 +632,8 @@ export function initSaveKeyboardShortcut() {
559
632
  export function initHyperclaySaveButton() {
560
633
  document.addEventListener("click", event => {
561
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();
562
637
  savePage();
563
638
  }
564
639
  });
@@ -571,6 +646,10 @@ export function initHyperclaySaveButton() {
571
646
  export function init() {
572
647
  if (!isEditMode) return;
573
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();
574
653
  initSaveKeyboardShortcut();
575
654
  initHyperclaySaveButton();
576
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
@@ -0,0 +1,96 @@
1
+ /**
2
+ * authored-url.js — keeping a runtime URL rewrite out of the saved file.
3
+ *
4
+ * cacheBust and refetch-on-save both rewrite an href/src after a save, so the
5
+ * browser re-fetches the asset. That write lands AFTER the save baseline was
6
+ * taken, so without help it reads as an edit the person made: the page goes
7
+ * dirty the instant a save succeeds, warns on close about work that was already
8
+ * written, and on an autosave page saves again immediately.
9
+ *
10
+ * The old answer stamped the element `no-trigger-autosave` on the way past. It
11
+ * worked only from the SECOND save on — the baseline already held the element
12
+ * unmarked, so the first comparison stripped something the baseline contained
13
+ * and the page read dirty anyway. It also spent a region marker, which changes
14
+ * how undo, autosave and live-sync treat that subtree forever, to paper over a
15
+ * serialization problem.
16
+ *
17
+ * This fixes the bytes instead. Remember what the URL was authored as, restore
18
+ * it on every snapshot clone, and leave the region model alone. The live DOM
19
+ * keeps the busted URL, the file keeps the authored one, and no comparison ever
20
+ * sees a difference.
21
+ */
22
+
23
+ const AUTHORED = 'clay-authored-url';
24
+ const RUNTIME = 'clay-runtime-url';
25
+ const ATTR = 'clay-url-attr';
26
+ const SUPERSEDED = 'clay-superseded';
27
+
28
+ // Every attribute this module owns. They live on the live element and are
29
+ // stripped from every clone, so none of them ever reaches the file.
30
+ const OWNED = [AUTHORED, RUNTIME, ATTR, SUPERSEDED];
31
+
32
+ /** Which attribute carries this element's URL, matching both call sites' rule. */
33
+ export function urlAttrFor(el) {
34
+ return el.hasAttribute('href') ? 'href' : el.hasAttribute('src') ? 'src' : null;
35
+ }
36
+
37
+ /**
38
+ * Write a clay-generated URL to the live DOM, remembering the authored one.
39
+ *
40
+ * Record-if-absent is the important part. A second cache-bust must not record
41
+ * the first bust's output as the authored value, or the file accumulates `?v=`
42
+ * stamps and the restore puts back a stale URL. It also makes the two helpers
43
+ * compose: when refetch clones an element cacheBust already touched, the clone
44
+ * carries the original authored value and keeps it.
45
+ */
46
+ export function writeRuntimeUrl(el, attr, value) {
47
+ if (!el.hasAttribute(AUTHORED)) {
48
+ el.setAttribute(AUTHORED, el.getAttribute(attr) ?? '');
49
+ el.setAttribute(ATTR, attr);
50
+ }
51
+ el.setAttribute(attr, value);
52
+ el.setAttribute(RUNTIME, value);
53
+ }
54
+
55
+ /**
56
+ * Mark an element that a replacement has been inserted next to.
57
+ *
58
+ * refetch-on-save leaves the old element in the DOM until the new one loads, so
59
+ * for up to two seconds the page holds both. Every snapshot taken in that window
60
+ * must serialize exactly one, or a capture differs from the baseline by a whole
61
+ * duplicated element.
62
+ */
63
+ export function markSuperseded(el) {
64
+ el.setAttribute(SUPERSEDED, '');
65
+ }
66
+
67
+ export function isSuperseded(el) {
68
+ return el.hasAttribute(SUPERSEDED);
69
+ }
70
+
71
+ /**
72
+ * Restore authored URLs on a snapshot clone and drop every attribute this
73
+ * module owns. Runs inside captureSnapshot, so it reaches the save clone, both
74
+ * comparison clones, and the live-sync broadcast alike.
75
+ */
76
+ export function restoreAuthoredUrls(clone) {
77
+ for (const el of clone.querySelectorAll(`[${SUPERSEDED}]`)) {
78
+ el.remove();
79
+ }
80
+
81
+ for (const el of clone.querySelectorAll(`[${AUTHORED}]`)) {
82
+ const attr = el.getAttribute(ATTR);
83
+ const authored = el.getAttribute(AUTHORED);
84
+ const runtime = el.getAttribute(RUNTIME);
85
+
86
+ // Restore only while the live value is still the one clay wrote. If the page
87
+ // has changed it since, that is a real authored edit: restoring would
88
+ // discard it silently, and keep discarding it on every save afterwards.
89
+ if (attr && el.getAttribute(attr) === runtime) {
90
+ if (authored) el.setAttribute(attr, authored);
91
+ else el.removeAttribute(attr);
92
+ }
93
+
94
+ for (const name of OWNED) el.removeAttribute(name);
95
+ }
96
+ }