@panphora/clayjs 0.2.1 → 0.4.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.
Files changed (45) hide show
  1. package/README.md +103 -7
  2. package/clay-data.js +1 -1
  3. package/clay-internals.js +19 -0
  4. package/clay.js +19 -3
  5. package/package.json +5 -2
  6. package/sap.js +5 -5
  7. package/src/attrs/onaftersave.js +12 -1
  8. package/src/attrs/refetch-on-save.js +4 -4
  9. package/src/attrs/save-freeze.js +47 -19
  10. package/src/core/admin-contenteditable.js +2 -2
  11. package/src/core/admin-inputs.js +2 -2
  12. package/src/core/admin-onclick.js +2 -2
  13. package/src/core/admin-resources.js +23 -14
  14. package/src/core/autosave.js +11 -1
  15. package/src/core/edit-mode.js +2 -2
  16. package/src/core/host-attrs.js +46 -0
  17. package/src/core/is-edit-mode.js +14 -6
  18. package/src/core/persist.js +12 -2
  19. package/src/core/save-core.js +199 -290
  20. package/src/core/save.js +121 -82
  21. package/src/core/snapshot.js +89 -36
  22. package/src/core/unsaved-warning.js +8 -2
  23. package/src/dom/all.js +4 -29
  24. package/src/dom/form-data.js +1 -1
  25. package/src/dom/nearest.js +11 -9
  26. package/src/internals/index.js +69 -0
  27. package/src/lib/cache-bust.js +10 -0
  28. package/src/lib/cookie.js +50 -15
  29. package/src/lib/extension-noise.js +5 -1
  30. package/src/lib/mutation.js +65 -18
  31. package/src/lib/region-policy.js +17 -2
  32. package/src/lib/root-attrs.js +45 -0
  33. package/src/lib/throttle.js +25 -13
  34. package/src/loader.js +18 -24
  35. package/src/options/options.js +1 -3
  36. package/src/plugins/demo.js +7 -1
  37. package/src/plugins/undo.js +3 -3
  38. package/src/sync/live-sync.js +77 -76
  39. package/src/ui/dialogs.js +10 -1
  40. package/src/ui/index.js +0 -10
  41. package/src/utils/debounce.js +24 -7
  42. package/src/vendor/control-serialize.vendor.js +2 -1
  43. package/src/vendor/hyper-undo.vendor.js +1 -1
  44. package/src/vendor/hypercms.vendor.js +29 -16
  45. package/src/vendor/richclay.vendor.js +29 -22
package/src/core/save.js CHANGED
@@ -16,14 +16,14 @@ import {
16
16
  saveHtml,
17
17
  getPageContents,
18
18
  replacePageWith as replacePageWithCore,
19
- beforeSave,
19
+ addDocumentTransform,
20
20
  isSaveInProgress
21
21
  } from "./save-core.js";
22
22
  import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
23
23
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
24
24
 
25
25
  // Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
26
- beforeSave(clone => {
26
+ addDocumentTransform(clone => {
27
27
  clone.setAttribute('savestatus', 'saved');
28
28
  });
29
29
 
@@ -92,27 +92,68 @@ window.addEventListener('online', () => {
92
92
  });
93
93
 
94
94
  // ============================================
95
- // POST-SAVE BASELINE RECAPTURE
95
+ // THE BASELINE IS THE BYTES WE SENT
96
96
  // ============================================
97
- // After a successful save, onaftersave handlers may modify the live DOM
98
- // (e.g., cacheBust updates ?v= query params). We recapture the baseline
99
- // after these sync handlers complete to prevent false "unsaved changes" warnings.
100
-
101
- document.addEventListener('clay:save-saved', () => {
102
- // Use setTimeout(0) to run after all sync onaftersave handlers complete
103
- setTimeout(() => {
104
- // Store stripped version so comparisons are direct (no parsing needed)
105
- const contents = captureForComparison();
106
- lastSavedContents = contents;
107
- logBaseline('recaptured after onaftersave', `${contents.length} chars`);
108
- }, 0);
109
- });
97
+ // There is deliberately no post-save re-read of the live DOM here.
98
+ //
99
+ // One used to exist, because [onaftersave] handlers mutate the page after a save
100
+ // lands (cacheBust rewrites ?v= query params) and without it the page read dirty
101
+ // forever. But a re-read cannot tell that churn apart from something the user typed
102
+ // while the request was in flight, so it recorded those keystrokes as saved without
103
+ // ever sending them: the edit was gone, with no error, no dirty flag, and no
104
+ // close-tab warning.
105
+ //
106
+ // Post-save mutators are made invisible to the comparison instead — they mark what
107
+ // they touch `no-trigger-autosave`, which strips it from every comparison capture
108
+ // (see cache-bust.js and refetch-on-save.js). That leaves the baseline free to stay
109
+ // exactly what savePage sent, which is the only value that is true by construction.
110
110
 
111
111
  // Re-export from core for backward compatibility
112
- export { beforeSave, getPageContents };
112
+ export { addDocumentTransform, getPageContents };
113
113
 
114
114
  let unsavedChanges = false;
115
115
  let lastSavedContents = '';
116
+ // A save was requested while one was on the wire; run one more when it settles.
117
+ let pendingSave = false;
118
+
119
+ function skipped_(msg) {
120
+ return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
121
+ }
122
+
123
+ /**
124
+ * Apply one save result to the page's state.
125
+ *
126
+ * A 'skipped' result means the bytes never reached the wire, so the baseline must
127
+ * NOT advance: recording content as saved when it was never sent is the same defect
128
+ * as re-reading the live DOM after a save, one layer down. 'unknown' is a timeout,
129
+ * where the write may or may not have landed, so it is not treated as success
130
+ * either.
131
+ */
132
+ function applySaveResult(result, forComparison, label) {
133
+ if (result.ok) {
134
+ lastSavedContents = forComparison;
135
+ unsavedChanges = false;
136
+ // The server's severity rides through untouched: a save can land AND carry a
137
+ // warning, and the UI module is what decides how to render that.
138
+ setSaveState('saved', result.msg || 'Saved', result.msgType);
139
+ logBaseline(label, `${lastSavedContents.length} chars`);
140
+ } else if (result.msgType !== 'skipped') {
141
+ if (!navigator.onLine) {
142
+ setSaveState('offline', result.msg);
143
+ } else {
144
+ setSaveState('error', result.msg);
145
+ }
146
+ }
147
+ }
148
+
149
+ // Run the save that arrived while this one was in flight. savePage does its own
150
+ // dirty check, so if nothing actually changed it resolves 'skipped' and stops:
151
+ // this cannot spin.
152
+ function drainPendingSave() {
153
+ if (!pendingSave) return;
154
+ pendingSave = false;
155
+ savePage();
156
+ }
116
157
 
117
158
  // State accessors for autosave module
118
159
  export function getUnsavedChanges() { return unsavedChanges; }
@@ -133,14 +174,18 @@ export function setLastSavedContents(val) { lastSavedContents = val; }
133
174
  export function savePage(callback = () => {}) {
134
175
  return new Promise((resolve) => {
135
176
  if (!isEditMode && !window.clay?.testMode) {
136
- const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
177
+ const skipped = skipped_('Not in edit mode');
137
178
  callback(skipped);
138
179
  return resolve(skipped);
139
180
  }
140
181
 
141
- // Don't start a new save if one is already in progress
182
+ // A save is already on the wire. Remember that a newer state is waiting rather
183
+ // than dropping it: the in-flight request carries the older bytes, and if no
184
+ // further mutation happens to retrigger autosave, the newer ones would never
185
+ // reach disk at all.
142
186
  if (isSaveInProgress()) {
143
- const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
187
+ pendingSave = true;
188
+ const skipped = skipped_('Save already in progress');
144
189
  callback(skipped);
145
190
  return resolve(skipped);
146
191
  }
@@ -155,13 +200,13 @@ export function savePage(callback = () => {}) {
155
200
  // Single capture: clone once, get both versions
156
201
  // forSave strips non-persisted regions ([no-save]/[save-remove])
157
202
  // forComparison additionally strips every autosave-off region
158
- let forSave, forComparison;
203
+ let forSave, forComparison, snapshotHtml;
159
204
  try {
160
- ({ forSave, forComparison } = captureForSaveAndComparison());
205
+ ({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
161
206
  } catch (err) {
162
207
  console.error('savePage: captureForSaveAndComparison failed', err);
163
208
  setSaveState('error', err.message);
164
- const result = { msg: err.message, msgType: 'error' };
209
+ const result = { msg: err.message, msgType: 'error', code: null, etag: null };
165
210
  if (typeof callback === 'function') {
166
211
  callback(result);
167
212
  }
@@ -174,7 +219,7 @@ export function savePage(callback = () => {}) {
174
219
 
175
220
  // Skip if content hasn't changed
176
221
  if (!unsavedChanges) {
177
- const skipped = { msg: 'No changes to save', msgType: 'skipped' };
222
+ const skipped = skipped_('No changes to save');
178
223
  callback(skipped);
179
224
  return resolve(skipped);
180
225
  }
@@ -183,32 +228,14 @@ export function savePage(callback = () => {}) {
183
228
  setSavingState();
184
229
 
185
230
  // Use saveHtml directly with our pre-captured content (avoids double capture)
186
- saveHtml(forSave, (err, data) => {
187
- if (!err) {
188
- // SUCCESS - store stripped version for future comparisons
189
- lastSavedContents = forComparison;
190
- unsavedChanges = false;
191
- setSaveState('saved', data?.msg || 'Saved', data?.msgType);
192
- logBaseline('updated after save', `${lastSavedContents.length} chars`);
193
- } else {
194
- // FAILED - determine if it's offline or server error
195
- if (!navigator.onLine) {
196
- setSaveState('offline', err.message);
197
- } else {
198
- setSaveState('error', err.message);
199
- }
200
- }
201
-
202
- // Call user callback if provided (preserve server's msgType)
203
- const result = {
204
- msg: err?.message || data?.msg,
205
- msgType: err ? 'error' : (data?.msgType || 'success')
206
- };
231
+ saveHtml(forSave, (result) => {
232
+ applySaveResult(result, forComparison, 'updated after save');
207
233
  if (typeof callback === 'function') {
208
234
  callback(result);
209
235
  }
210
236
  resolve(result);
211
- });
237
+ drainPendingSave();
238
+ }, { snapshotHtml });
212
239
  });
213
240
  }
214
241
 
@@ -221,13 +248,14 @@ export function savePage(callback = () => {}) {
221
248
  export function savePageForce(callback = () => {}) {
222
249
  return new Promise((resolve) => {
223
250
  if (!isEditMode && !window.clay?.testMode) {
224
- const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
251
+ const skipped = skipped_('Not in edit mode');
225
252
  callback(skipped);
226
253
  return resolve(skipped);
227
254
  }
228
255
 
229
256
  if (isSaveInProgress()) {
230
- const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
257
+ pendingSave = true;
258
+ const skipped = skipped_('Save already in progress');
231
259
  callback(skipped);
232
260
  return resolve(skipped);
233
261
  }
@@ -237,13 +265,13 @@ export function savePageForce(callback = () => {}) {
237
265
  setOfflineStateQuiet();
238
266
  }
239
267
 
240
- let forSave, forComparison;
268
+ let forSave, forComparison, snapshotHtml;
241
269
  try {
242
- ({ forSave, forComparison } = captureForSaveAndComparison());
270
+ ({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
243
271
  } catch (err) {
244
272
  console.error('savePageForce: captureForSaveAndComparison failed', err);
245
273
  setSaveState('error', err.message);
246
- const result = { msg: err.message, msgType: 'error' };
274
+ const result = { msg: err.message, msgType: 'error', code: null, etag: null };
247
275
  if (typeof callback === 'function') {
248
276
  callback(result);
249
277
  }
@@ -252,29 +280,14 @@ export function savePageForce(callback = () => {}) {
252
280
 
253
281
  setSavingState();
254
282
 
255
- saveHtml(forSave, (err, data) => {
256
- if (!err) {
257
- lastSavedContents = forComparison;
258
- unsavedChanges = false;
259
- setSaveState('saved', data?.msg || 'Saved', data?.msgType);
260
- logBaseline('updated after force save', `${lastSavedContents.length} chars`);
261
- } else {
262
- if (!navigator.onLine) {
263
- setSaveState('offline', err.message);
264
- } else {
265
- setSaveState('error', err.message);
266
- }
267
- }
268
-
269
- const result = {
270
- msg: err?.message || data?.msg,
271
- msgType: err ? 'error' : (data?.msgType || 'success')
272
- };
283
+ saveHtml(forSave, (result) => {
284
+ applySaveResult(result, forComparison, 'updated after force save');
273
285
  if (typeof callback === 'function') {
274
286
  callback(result);
275
287
  }
276
288
  resolve(result);
277
- });
289
+ drainPendingSave();
290
+ }, { snapshotHtml });
278
291
  });
279
292
  }
280
293
 
@@ -289,13 +302,16 @@ export function replacePageWith(url) {
289
302
  return;
290
303
  }
291
304
 
292
- replacePageWithCore(url, (err, data) => {
293
- if (err) {
294
- // Emit error event (save-toast will show toast if loaded)
295
- setSaveState('error', err.message || "Failed to save template");
296
- } else {
297
- // Only reload if save was successful
305
+ replacePageWithCore(url, (result) => {
306
+ // Reload ONLY on a save that actually landed. A skipped result means the
307
+ // replacement never happened (busy lane, or not in edit mode), and reloading
308
+ // then presented a no-op as a completed swap.
309
+ if (result.ok) {
298
310
  window.location.reload();
311
+ } else if (result.msgType === 'skipped') {
312
+ setSaveState('error', result.msg || 'Template not saved');
313
+ } else {
314
+ setSaveState('error', result.msg || 'Failed to save template');
299
315
  }
300
316
  });
301
317
  }
@@ -305,6 +321,9 @@ const throttledSave = throttle(savePage, 1200);
305
321
 
306
322
  // Baseline for autosave comparison
307
323
  let baselineContents = '';
324
+ // The baseline veto only guards the load-time settle window; captureBaseline
325
+ // disarms it. See the comment there.
326
+ let baselineActive = true;
308
327
 
309
328
  // ============================================
310
329
  // BASELINE CAPTURE (Settled Signal)
@@ -372,6 +391,14 @@ function initBaselineCapture() {
372
391
  logBaseline('settled skipped', userEdited ? 'user edited' : 'save occurred during settle');
373
392
  }
374
393
 
394
+ // The load-time veto has done its job. It exists so setup churn from modules
395
+ // booting cannot trigger a save, and that window is over once mutations have
396
+ // settled. Leaving it armed for the life of the tab is what made an undo back
397
+ // to the page's original state unsaveable: it differed from lastSavedContents
398
+ // but matched baselineContents, so autosave vetoed it forever and only a manual
399
+ // save could persist the revert.
400
+ baselineActive = false;
401
+
375
402
  document.documentElement.setAttribute('savestatus', 'saved');
376
403
  };
377
404
 
@@ -408,29 +435,41 @@ if (document.readyState === 'loading') {
408
435
  */
409
436
  export function savePageThrottled(callback = () => {}) {
410
437
  if (!isEditMode) {
411
- const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
438
+ const skipped = skipped_('Not in edit mode');
412
439
  callback(skipped);
413
440
  return Promise.resolve(skipped);
414
441
  }
415
442
 
416
- // For autosave: check both that content changed from baseline AND from last save
417
- // This prevents saves from initial setup mutations
443
+ // For autosave: while the page is still settling, content must differ from BOTH
444
+ // the load-time baseline and the last save, so module setup churn cannot trigger
445
+ // a save. Once settled, the baseline veto is disarmed and only the last save
446
+ // matters — otherwise undoing back to the page's original state can never be
447
+ // persisted, because it matches the baseline forever.
418
448
  // Compare directly - stored versions are already stripped
419
449
  const currentForCompare = captureForComparison();
420
- const differsFromBaseline = currentForCompare !== baselineContents;
450
+ const differsFromBaseline = !baselineActive || currentForCompare !== baselineContents;
421
451
  const differsFromLastSave = currentForCompare !== lastSavedContents;
422
452
 
423
453
  logSaveCheck('throttled vs baseline', !differsFromBaseline);
424
454
  logSaveCheck('throttled vs lastSave', !differsFromLastSave);
425
455
 
426
456
  if (!(differsFromBaseline && differsFromLastSave)) {
427
- const skipped = { msg: 'No changes to save', msgType: 'skipped' };
457
+ const skipped = skipped_('No changes to save');
428
458
  callback(skipped);
429
459
  return Promise.resolve(skipped);
430
460
  }
431
461
 
432
462
  unsavedChanges = true;
433
- return throttledSave(callback);
463
+ // The throttled promise can reject now that a throwing save no longer strands
464
+ // its callers (see throttle.js), and both autosave call sites are fire-and-forget,
465
+ // with no reach into this module's error path. Catch it here, the one place that
466
+ // owns that path, so every caller keeps the never-rejects contract savePage
467
+ // documents and a throw surfaces as a failed save instead of an unhandled rejection.
468
+ return throttledSave(callback).catch(err => {
469
+ const result = { ok: false, msg: err?.message || 'Save failed', msgType: 'error', code: null, etag: null };
470
+ setSaveState('error', result.msg);
471
+ return result;
472
+ });
434
473
  }
435
474
 
436
475
  /**
@@ -19,7 +19,7 @@
19
19
  * ▼ ▼
20
20
  * ┌─────────────────────────┐ ┌─────────────────────────┐
21
21
  * │ 3a. PREPARE HOOKS │ │ 3b. DONE │
22
- * │ onPrepareForSave │ │ (live-sync stops here) │
22
+ * │ addDocumentTransform │ │ (live-sync stops here) │
23
23
  * │ [onbeforesave] │ │ │
24
24
  * │ [save-remove] │ │ → emits snapshot-ready │
25
25
  * │ │ └─────────────────────────┘
@@ -38,13 +38,38 @@
38
38
 
39
39
  import { stripExtensionNoise } from '../lib/extension-noise.js';
40
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';
42
+ import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
41
43
 
42
44
  // =============================================================================
43
45
  // HOOK REGISTRIES
44
46
  // =============================================================================
45
47
 
46
48
  const snapshotHooks = []; // Phase 2: Always run (form sync)
47
- const prepareForSaveHooks = []; // Phase 3a: Save only (strip admin)
49
+ const documentTransforms = []; // Phase 3a: Save only (strip admin)
50
+
51
+ /**
52
+ * Run every authored handler of one kind over a clone, and never let one of them
53
+ * take the page down with it.
54
+ *
55
+ * These attributes hold page-author JavaScript, and the capture pipeline runs on
56
+ * the boot path: save.js captures a baseline during module evaluation, inside the
57
+ * dynamic import the loader awaits. An unguarded throw in one page attribute
58
+ * therefore cost the document its entire client rather than costing that one
59
+ * handler its effect.
60
+ *
61
+ * @param {HTMLElement} clone
62
+ * @param {string} attr - e.g. 'onbeforesave'
63
+ */
64
+ function runAuthoredHandlers(clone, attr) {
65
+ for (const el of clone.querySelectorAll(`[${attr}]`)) {
66
+ try {
67
+ new Function(el.getAttribute(attr)).call(el);
68
+ } catch (err) {
69
+ console.error(`[${attr}] handler failed:`, err);
70
+ }
71
+ }
72
+ }
48
73
 
49
74
  /**
50
75
  * Register a hook that runs on EVERY snapshot (save AND sync).
@@ -57,18 +82,21 @@ export function onSnapshot(callback) {
57
82
  }
58
83
 
59
84
  /**
60
- * Register a hook that runs ONLY when preparing for save.
85
+ * Register a transform that runs over a detached clone when preparing to save.
61
86
  * Use for: stripping admin elements, cleanup.
62
87
  *
88
+ * It is a TRANSFORM, not a save lifecycle event: it runs over the clone that
89
+ * gets saved AND over the clone used to answer "has anything changed", and that
90
+ * second one runs on dirty checks that never become a save. So it must be pure
91
+ * and repeatable — no counters, no logging, no network. The two clones have
92
+ * different visibility rules, which is why neither run can be skipped.
93
+ *
63
94
  * @param {Function} callback - Receives the cloned document element
64
95
  */
65
- export function onPrepareForSave(callback) {
66
- prepareForSaveHooks.push(callback);
96
+ export function addDocumentTransform(callback) {
97
+ documentTransforms.push(callback);
67
98
  }
68
99
 
69
- // Backwards compat alias
70
- export const beforeSave = onPrepareForSave;
71
-
72
100
  // =============================================================================
73
101
  // CAPTURE FUNCTIONS
74
102
  // =============================================================================
@@ -94,8 +122,8 @@ export function captureSnapshot() {
94
122
  // mid-typing would leave the idle batch open across the save boundary, and
95
123
  // Cmd+Z after save would restore to a state earlier than the last save.
96
124
  // No-op when undo isn't loaded or no batch is pending.
97
- if (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo && window.hyperclay.undo.flush) {
98
- window.hyperclay.undo.flush();
125
+ if (typeof window !== 'undefined' && window.clay?.undo?.flush) {
126
+ window.clay.undo.flush();
99
127
  }
100
128
 
101
129
  const clone = clonePreventingOnclone(document.documentElement);
@@ -104,9 +132,7 @@ export function captureSnapshot() {
104
132
  hook(clone);
105
133
  }
106
134
 
107
- for (const el of clone.querySelectorAll('[onbeforesnapshot]')) {
108
- new Function(el.getAttribute('onbeforesnapshot')).call(el);
109
- }
135
+ runAuthoredHandlers(clone, 'onbeforesnapshot');
110
136
 
111
137
  for (const el of clone.querySelectorAll(SNAPSHOT_REMOVE_SELECTOR)) {
112
138
  el.remove();
@@ -129,12 +155,10 @@ export function captureSnapshot() {
129
155
  */
130
156
  function prepareCloneForSave(clone) {
131
157
  // Run inline [onbeforesave] handlers
132
- for (const el of clone.querySelectorAll('[onbeforesave]')) {
133
- new Function(el.getAttribute('onbeforesave')).call(el);
134
- }
158
+ runAuthoredHandlers(clone, 'onbeforesave');
135
159
 
136
160
  // Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
137
- for (const hook of prepareForSaveHooks) {
161
+ for (const hook of documentTransforms) {
138
162
  hook(clone);
139
163
  }
140
164
 
@@ -161,9 +185,7 @@ export function captureForComparison() {
161
185
  const clone = captureSnapshot();
162
186
 
163
187
  // Run inline [onbeforesave] handlers
164
- for (const el of clone.querySelectorAll('[onbeforesave]')) {
165
- new Function(el.getAttribute('onbeforesave')).call(el);
166
- }
188
+ runAuthoredHandlers(clone, 'onbeforesave');
167
189
 
168
190
  // Strip before hooks (hooks see the "final" state)
169
191
  for (const el of clone.querySelectorAll(STRIP_FROM_COMPARISON)) {
@@ -171,7 +193,7 @@ export function captureForComparison() {
171
193
  }
172
194
 
173
195
  // Run registered prepare hooks
174
- for (const hook of prepareForSaveHooks) {
196
+ for (const hook of documentTransforms) {
175
197
  hook(clone);
176
198
  }
177
199
 
@@ -186,37 +208,39 @@ export function captureForComparison() {
186
208
  *
187
209
  * @param {Object} options
188
210
  * @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
189
- * @returns {{ forSave: string, forComparison: string }}
211
+ * @returns {{ forSave: string, forComparison: string, snapshotHtml: ?string }}
190
212
  */
191
213
  export function captureForSaveAndComparison({ emitForSync = true } = {}) {
192
214
  const clone = captureSnapshot();
193
215
 
216
+ // The unstripped snapshot, for a host that asked for the desktop JSON envelope:
217
+ // the save then sends both the stripped document and this. Returned to the caller
218
+ // rather than parked on a window global, so it can only ever be paired with the
219
+ // content captured alongside it. As a global it was cleared on success only, so
220
+ // two captures without an intervening successful save shipped a stale snapshot
221
+ // next to fresh content. Captured only when the document DECLARES the transport;
222
+ // this used to key off `location.hostname`, which set it on every localhost page
223
+ // whether or not its host wanted it.
224
+ const snapshotHtml = saveTransport() === DESKTOP_JSON
225
+ ? '<!DOCTYPE html>' + clone.outerHTML
226
+ : null;
227
+
194
228
  // Emit for live-sync before any stripping
195
229
  if (emitForSync) {
196
230
  document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
197
231
  detail: { documentElement: clone }
198
232
  }));
199
-
200
- // Store snapshot HTML for Hyperclay Local platform sync
201
- // This allows the save system to send both stripped and full versions
202
- const isHyperclayLocal = window.location.hostname === 'localhost' ||
203
- window.location.hostname === '127.0.0.1';
204
- if (isHyperclayLocal) {
205
- window.__hyperclaySnapshotHtml = '<!DOCTYPE html>' + clone.outerHTML;
206
- }
207
233
  }
208
234
 
209
235
  // Run inline [onbeforesave] handlers
210
- for (const el of clone.querySelectorAll('[onbeforesave]')) {
211
- new Function(el.getAttribute('onbeforesave')).call(el);
212
- }
236
+ runAuthoredHandlers(clone, 'onbeforesave');
213
237
 
214
238
  // Clone for comparison before stripping (cheaper than cloning live DOM)
215
239
  const compareClone = clonePreventingOnclone(clone);
216
240
 
217
241
  // Save clone: run hooks (freeze restore lives here), THEN strip [no-save]/[save-remove]
218
242
  // LAST (snapshot-algorithm step 7) so freeze-restored [no-save] content can't leak to disk.
219
- for (const hook of prepareForSaveHooks) {
243
+ for (const hook of documentTransforms) {
220
244
  hook(clone);
221
245
  }
222
246
  for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
@@ -228,12 +252,12 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
228
252
  for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
229
253
  el.remove();
230
254
  }
231
- for (const hook of prepareForSaveHooks) {
255
+ for (const hook of documentTransforms) {
232
256
  hook(compareClone);
233
257
  }
234
258
  const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
235
259
 
236
- return { forSave, forComparison };
260
+ return { forSave, forComparison, snapshotHtml };
237
261
  }
238
262
 
239
263
  /**
@@ -260,6 +284,35 @@ export function captureForSave({ emitForSync = true } = {}) {
260
284
  return prepareCloneForSave(clone);
261
285
  }
262
286
 
287
+ /**
288
+ * The bytes a live-sync broadcast carries: the snapshot, minus the root
289
+ * attributes that belong to this tab alone. A third serialization of the same
290
+ * clone alongside the snapshot and the document, and the only one that crosses
291
+ * into another person's browser.
292
+ *
293
+ * Takes the clone rather than capturing one, because the caller already has the
294
+ * snapshot-ready clone and capturing again would run every hook a second time.
295
+ * (Not to be confused with captureBodyForSync below, which is the older
296
+ * body-innerHTML helper and unrelated to the live-sync lane.)
297
+ *
298
+ * The clone is detached and unobserved, so removing the attributes in place and
299
+ * putting them back is exact, and far cheaper than cloning the tree again. The
300
+ * finally is load-bearing: the save path reads this same clone afterwards.
301
+ */
302
+ export function serializeForSync(clone) {
303
+ const removed = [];
304
+ for (const name of TAB_LOCAL_ROOT_ATTRS) {
305
+ if (!clone.hasAttribute(name)) continue;
306
+ removed.push([name, clone.getAttribute(name)]);
307
+ clone.removeAttribute(name);
308
+ }
309
+ try {
310
+ return clone.outerHTML;
311
+ } finally {
312
+ for (const [name, value] of removed) clone.setAttribute(name, value);
313
+ }
314
+ }
315
+
263
316
  /**
264
317
  * PHASE 1-2 (body only): For live-sync between admin users.
265
318
  *
@@ -13,7 +13,7 @@
13
13
  * Requires the 'save-system' module (automatically included as dependency).
14
14
  */
15
15
 
16
- import { isOwner, isEditMode } from "./is-edit-mode.js";
16
+ import { isEditMode } from "./is-edit-mode.js";
17
17
  import { captureForComparison } from "./snapshot.js";
18
18
  import { getLastSavedContents } from "./save.js";
19
19
  import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
@@ -21,8 +21,14 @@ import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
21
21
  // Pre-load diff library if debug mode is on (so it's ready for unload)
22
22
  preloadIfEnabled();
23
23
 
24
+ // Gated on isEditMode, not isOwner. isOwner means the platform's admin cookie
25
+ // specifically, so gating on it switched the warning off for every host that
26
+ // authenticates another way: htmlclay, anything using a root save token, and any
27
+ // sandboxed document, which cannot read cookies at all. Those are exactly the
28
+ // documents where an unsaved edit is easiest to lose. If the page is editable,
29
+ // the person editing it deserves the warning.
24
30
  window.addEventListener('beforeunload', (event) => {
25
- if (!isOwner || !isEditMode) return;
31
+ if (!isEditMode) return;
26
32
 
27
33
  // Compare directly - both are already stripped
28
34
  const currentForCompare = captureForComparison();
package/src/dom/all.js CHANGED
@@ -156,36 +156,9 @@ const createMethodHandler = (elements, plugins, methods) => ({
156
156
  // - When a method returns an Element (like cloneNode), it wraps it in a proxy
157
157
  // - When a method returns undefined (like removeAttribute), it chains on the original elements
158
158
  // - For other return values, like strings, it returns the results in an array
159
- // We also handle passing in an all-wrapped proxy object as an argument and loop over all elements in it
160
- // In the method handler's get function, replace the function call handling with:
161
159
  if (typeof value === 'function') {
162
160
  return (...args) => {
163
- // Unwrap any proxy arguments
164
- const unwrappedArgs = args.map(arg => {
165
- if (arg && arg.constructor === Proxy) {
166
- return Array.from(arg);
167
- }
168
- return arg;
169
- });
170
-
171
- const results = elements.map(el => {
172
- // Check if any of the unwrapped arguments are arrays (from proxies)
173
- const hasProxyArgs = unwrappedArgs.some(Array.isArray);
174
-
175
- if (hasProxyArgs) {
176
- // Handle proxy arguments case
177
- const elementResults = unwrappedArgs.map(arg => {
178
- if (Array.isArray(arg)) {
179
- return arg.map(proxyEl => el[prop](proxyEl));
180
- }
181
- return [el[prop](...args)];
182
- }).flat();
183
- return elementResults[elementResults.length - 1];
184
- } else {
185
- // Simple case - just call the method once with original arguments
186
- return el[prop](...args);
187
- }
188
- });
161
+ const results = elements.map(el => el[prop](...args));
189
162
 
190
163
  if (results[0] instanceof Element) {
191
164
  return createElementProxy(results.filter(Boolean), plugins, methods);
@@ -390,7 +363,9 @@ const All = new Proxy(function (selectorOrElements, contextSelector) {
390
363
  if (typeof selectorOrElements === 'string') {
391
364
  const elements = contextElements.flatMap(context => {
392
365
  // Include context itself if it matches the selector
393
- const matches = context.matches(selectorOrElements) ? [context] : [];
366
+ // Document has no matches(): it can never be its own match, only a root
367
+ // to search under.
368
+ const matches = context.matches?.(selectorOrElements) ? [context] : [];
394
369
  // Plus all descendants that match
395
370
  const descendants = Array.from(context.querySelectorAll(selectorOrElements));
396
371
  return [...matches, ...descendants];
@@ -5,7 +5,7 @@ function getDataFromForm(container) {
5
5
  // Helper function to process a single element
6
6
  const processElement = (elem) => {
7
7
  const name = elem.getAttribute('name');
8
- const value = elem.value || elem.getAttribute('value');
8
+ const value = elem.value ?? elem.getAttribute('value');
9
9
 
10
10
  // Skip elements without a name or with a disabled attribute
11
11
  if (!name || elem.disabled) return;