@panphora/clayjs 1.6.0 → 1.6.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.
@@ -1,4 +1,4 @@
1
- /* clayjs 1.6.0 standalone build: every clayjs module in one file. https://clayjs.com/offline
1
+ /* clayjs 1.6.1 standalone build: every clayjs module in one file. https://clayjs.com/offline
2
2
  Third-party code inside keeps its own license (Sortable MIT, Squire MIT, DOMPurify Apache-2.0 OR MPL-2.0, MicroModal MIT, parse5 MIT): https://clayjs.com/THIRD-PARTY-NOTICES.md */
3
3
  (() => {
4
4
  var __create = Object.create;
@@ -2537,8 +2537,17 @@
2537
2537
  replacePageWith: () => replacePageWith,
2538
2538
  resetSaveAttempts: () => resetSaveAttempts,
2539
2539
  saveFateIsUnknown: () => saveFateIsUnknown,
2540
- saveHtml: () => saveHtml
2540
+ saveHtml: () => saveHtml,
2541
+ withKeepalive: () => withKeepalive
2541
2542
  });
2543
+ async function withKeepalive(keepalive, run) {
2544
+ nextSaveKeepalive = Boolean(keepalive);
2545
+ try {
2546
+ return await run();
2547
+ } finally {
2548
+ nextSaveKeepalive = false;
2549
+ }
2550
+ }
2542
2551
  function isSaveInProgress() {
2543
2552
  return saveInProgress;
2544
2553
  }
@@ -2612,7 +2621,7 @@
2612
2621
  function skippedResult(msg) {
2613
2622
  return { ok: false, msg, msgType: "skipped", code: null, etag: null };
2614
2623
  }
2615
- function buildSaveRequest(content, userDriven, signal, saveId, etag) {
2624
+ function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive = false) {
2616
2625
  const token = saveToken();
2617
2626
  const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
2618
2627
  const options = {
@@ -2637,6 +2646,7 @@
2637
2646
  body: content
2638
2647
  };
2639
2648
  if (etag) options.headers["If-Match"] = etag;
2649
+ if (keepalive && new Blob([content]).size < KEEPALIVE_MAX_BODY) options.keepalive = true;
2640
2650
  return { url: resolveSaveUrl(path), options };
2641
2651
  }
2642
2652
  function stampToSend() {
@@ -2654,7 +2664,9 @@
2654
2664
  const gestureDriven = consumeUserDriven();
2655
2665
  const explicitlyAsked = consumeExplicitSave();
2656
2666
  const userDriven = gestureDriven || explicitlyAsked;
2657
- const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag);
2667
+ const keepalive = nextSaveKeepalive;
2668
+ nextSaveKeepalive = false;
2669
+ const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag, keepalive);
2658
2670
  rememberSentId(saveId);
2659
2671
  return fetch(url, options).then((res) => res.text().then((text) => {
2660
2672
  let data = {};
@@ -2779,7 +2791,7 @@
2779
2791
  });
2780
2792
  });
2781
2793
  }
2782
- var saveInProgress, SAVE_PATH, SAVE_TIMEOUT_MS, saveAcceptedHooks, sentIds, SENT_ID_MEMORY, unknownAttempt;
2794
+ var saveInProgress, SAVE_PATH, SAVE_TIMEOUT_MS, KEEPALIVE_MAX_BODY, nextSaveKeepalive, saveAcceptedHooks, sentIds, SENT_ID_MEMORY, unknownAttempt;
2783
2795
  var init_save_core = __esm({
2784
2796
  "src/core/save-core.js"() {
2785
2797
  init_is_edit_mode();
@@ -2791,6 +2803,8 @@
2791
2803
  saveInProgress = false;
2792
2804
  SAVE_PATH = "/_/save";
2793
2805
  SAVE_TIMEOUT_MS = 12e3;
2806
+ KEEPALIVE_MAX_BODY = 6e4;
2807
+ nextSaveKeepalive = false;
2794
2808
  saveAcceptedHooks = [];
2795
2809
  sentIds = [];
2796
2810
  SENT_ID_MEMORY = 8;
@@ -2864,6 +2878,18 @@
2864
2878
  });
2865
2879
 
2866
2880
  // src/lib/dirty-gate.js
2881
+ function scheduleAnnounce() {
2882
+ if (!isEditMode || queued) return;
2883
+ queued = true;
2884
+ queueMicrotask(() => {
2885
+ queued = false;
2886
+ if (typeof document === "undefined") return;
2887
+ const dirty = pageMaybeDirty();
2888
+ if (dirty === announcedDirty) return;
2889
+ announcedDirty = dirty;
2890
+ document.dispatchEvent(new CustomEvent(dirty ? "clay:dirty" : "clay:clean"));
2891
+ });
2892
+ }
2867
2893
  function gatedPersistControls() {
2868
2894
  const out = [];
2869
2895
  for (const el5 of document.querySelectorAll(PERSIST_CONTROLS)) {
@@ -2878,6 +2904,7 @@
2878
2904
  if (!(el5.matches("input, textarea, select") || el5.isContentEditable)) return;
2879
2905
  if (el5.closest(GATE_IGNORE)) return;
2880
2906
  changes++;
2907
+ scheduleAnnounce();
2881
2908
  }
2882
2909
  function startDirtyGate() {
2883
2910
  if (started) return;
@@ -2885,7 +2912,10 @@
2885
2912
  mutation_default.onAnyChange(
2886
2913
  { omitChangeDetails: true, require: "dirty" },
2887
2914
  () => {
2888
- if (!paused) changes++;
2915
+ if (!paused) {
2916
+ changes++;
2917
+ scheduleAnnounce();
2918
+ }
2889
2919
  }
2890
2920
  );
2891
2921
  document.addEventListener("input", onUserInput, true);
@@ -2948,6 +2978,7 @@
2948
2978
  }
2949
2979
  function gateMarkDirty() {
2950
2980
  changes++;
2981
+ scheduleAnnounce();
2951
2982
  }
2952
2983
  function gateClearIfUnchanged(token) {
2953
2984
  if (!token) return;
@@ -2957,8 +2988,9 @@
2957
2988
  for (const [el5, sig] of token.probe) {
2958
2989
  if (controlSignature(el5) === sig) probeCache.set(el5, sig);
2959
2990
  }
2991
+ scheduleAnnounce();
2960
2992
  }
2961
- var changes, clearedAt, paused, started, PERSIST_CONTROLS, GATE_IGNORE, probeCache;
2993
+ var changes, clearedAt, paused, started, announcedDirty, queued, PERSIST_CONTROLS, GATE_IGNORE, probeCache;
2962
2994
  var init_dirty_gate = __esm({
2963
2995
  "src/lib/dirty-gate.js"() {
2964
2996
  init_mutation();
@@ -2969,6 +3001,8 @@
2969
3001
  clearedAt = 0;
2970
3002
  paused = false;
2971
3003
  started = false;
3004
+ announcedDirty = false;
3005
+ queued = false;
2972
3006
  PERSIST_CONTROLS = `input${PERSIST2}, textarea${PERSIST2}, select${PERSIST2}`;
2973
3007
  GATE_IGNORE = `${STRIP_FROM_DIRTY_CHECK}, ${SNAPSHOT_REMOVE_SELECTOR}`;
2974
3008
  probeCache = /* @__PURE__ */ new WeakMap();
@@ -2982,6 +3016,52 @@
2982
3016
  }
2983
3017
  });
2984
3018
 
3019
+ // src/lib/unsaved-state.js
3020
+ function registerUnsavedState({ id = "", isPending: isPending2 }) {
3021
+ const key2 = ++nextKey;
3022
+ sources.set(key2, { id, isPending: isPending2 });
3023
+ let disposed = false;
3024
+ return {
3025
+ changed() {
3026
+ if (disposed) return;
3027
+ document.dispatchEvent(new CustomEvent("clay:unsaved-state-changed", {
3028
+ detail: { pending: pendingIds() }
3029
+ }));
3030
+ },
3031
+ dispose() {
3032
+ if (disposed) return;
3033
+ disposed = true;
3034
+ sources.delete(key2);
3035
+ }
3036
+ };
3037
+ }
3038
+ function isPending(source2) {
3039
+ try {
3040
+ return !!source2.isPending();
3041
+ } catch (err) {
3042
+ console.error(`[clay] unsaved-state check "${source2.id}" threw`, err);
3043
+ return true;
3044
+ }
3045
+ }
3046
+ function pendingIds() {
3047
+ return [...sources.values()].filter(isPending).map((s2) => s2.id);
3048
+ }
3049
+ function hasUnsavedState() {
3050
+ for (const source2 of sources.values()) if (isPending(source2)) return true;
3051
+ return false;
3052
+ }
3053
+ var sources, nextKey;
3054
+ var init_unsaved_state = __esm({
3055
+ "src/lib/unsaved-state.js"() {
3056
+ sources = /* @__PURE__ */ new Map();
3057
+ nextKey = 0;
3058
+ if (typeof window !== "undefined") {
3059
+ window.clay = window.clay || {};
3060
+ window.clay.registerUnsavedState = registerUnsavedState;
3061
+ }
3062
+ }
3063
+ });
3064
+
2985
3065
  // src/lib/autosave-state.js
2986
3066
  function setAutosaveActive(value) {
2987
3067
  active = !!value;
@@ -3094,6 +3174,67 @@
3094
3174
  }
3095
3175
  });
3096
3176
 
3177
+ // src/core/unsaved-warning.js
3178
+ var unsaved_warning_exports = {};
3179
+ __export(unsaved_warning_exports, {
3180
+ bytesUnsaved: () => bytesUnsaved,
3181
+ hasUnsavedChanges: () => hasUnsavedChanges,
3182
+ reloadAfterDiscard: () => reloadAfterDiscard
3183
+ });
3184
+ function reloadAfterDiscard({ isCurrent, reload }) {
3185
+ if (!isCurrent()) return false;
3186
+ discardPermit = true;
3187
+ setTimeout(() => {
3188
+ discardPermit = false;
3189
+ }, 1e3);
3190
+ reload();
3191
+ return true;
3192
+ }
3193
+ function hasUnsavedChanges() {
3194
+ if (!isEditMode) return false;
3195
+ if (hasUnsavedState()) return true;
3196
+ if (window.clay?.demo) return false;
3197
+ return bytesUnsaved();
3198
+ }
3199
+ function bytesUnsaved() {
3200
+ if (!isEditMode) return false;
3201
+ try {
3202
+ return captureForDirtyCheck() !== getLastSavedDirty();
3203
+ } catch {
3204
+ return true;
3205
+ }
3206
+ }
3207
+ var discardPermit;
3208
+ var init_unsaved_warning = __esm({
3209
+ "src/core/unsaved-warning.js"() {
3210
+ init_is_edit_mode();
3211
+ init_snapshot();
3212
+ init_save();
3213
+ init_autosave_debug();
3214
+ init_unsaved_state();
3215
+ discardPermit = false;
3216
+ preloadIfEnabled();
3217
+ window.addEventListener("beforeunload", (event) => {
3218
+ if (!isEditMode) return;
3219
+ if (discardPermit) {
3220
+ discardPermit = false;
3221
+ return;
3222
+ }
3223
+ if (!hasUnsavedChanges()) return;
3224
+ event.preventDefault();
3225
+ event.returnValue = "";
3226
+ if (!window.clay?.demo && !hasUnsavedState()) {
3227
+ try {
3228
+ const currentForCompare = captureForDirtyCheck();
3229
+ const lastSaved = getLastSavedDirty();
3230
+ if (currentForCompare !== lastSaved) logUnloadDiffSync(currentForCompare, lastSaved);
3231
+ } catch {
3232
+ }
3233
+ }
3234
+ });
3235
+ }
3236
+ });
3237
+
3097
3238
  // src/core/save.js
3098
3239
  var save_exports = {};
3099
3240
  __export(save_exports, {
@@ -3101,6 +3242,7 @@
3101
3242
  baselineSettled: () => baselineSettled,
3102
3243
  conflictResolvedBySync: () => conflictResolvedBySync,
3103
3244
  default: () => save_default,
3245
+ flushSave: () => flushSave,
3104
3246
  getLastSavedBytes: () => getLastSavedBytes,
3105
3247
  getLastSavedContents: () => getLastSavedContents,
3106
3248
  getLastSavedDirty: () => getLastSavedDirty,
@@ -3366,6 +3508,85 @@
3366
3508
  });
3367
3509
  });
3368
3510
  }
3511
+ function settled(deadline) {
3512
+ const events = ["clay:save-saved", "clay:save-error", "clay:save-offline", "clay:save-conflict"];
3513
+ return new Promise((resolve, reject) => {
3514
+ let timer = null;
3515
+ const cleanup = () => {
3516
+ clearTimeout(timer);
3517
+ for (const name of events) document.removeEventListener(name, onSettle);
3518
+ };
3519
+ const onSettle = () => {
3520
+ cleanup();
3521
+ resolve();
3522
+ };
3523
+ const remaining = deadline - Date.now();
3524
+ if (remaining <= 0) {
3525
+ reject(Object.assign(new Error("Save timed out."), { state: "failed" }));
3526
+ return;
3527
+ }
3528
+ timer = setTimeout(() => {
3529
+ cleanup();
3530
+ reject(Object.assign(new Error("Save timed out."), { state: "failed" }));
3531
+ }, remaining);
3532
+ for (const name of events) document.addEventListener(name, onSettle);
3533
+ });
3534
+ }
3535
+ function flushError(message, state2) {
3536
+ return Object.assign(new Error(message), { state: state2 });
3537
+ }
3538
+ function nextEvent(name, deadline) {
3539
+ return new Promise((resolve, reject) => {
3540
+ const remaining = deadline - Date.now();
3541
+ if (remaining <= 0) return reject(flushError("Save timed out.", "failed"));
3542
+ const timer = setTimeout(() => {
3543
+ document.removeEventListener(name, onEvent);
3544
+ reject(flushError("Save timed out.", "failed"));
3545
+ }, remaining);
3546
+ const onEvent = () => {
3547
+ clearTimeout(timer);
3548
+ resolve();
3549
+ };
3550
+ document.addEventListener(name, onEvent, { once: true });
3551
+ });
3552
+ }
3553
+ async function flushSave({ keepalive = false, timeoutMs = 15e3 } = {}) {
3554
+ if (!isEditMode) return { state: "view", etag: null };
3555
+ const deadline = Date.now() + timeoutMs;
3556
+ let timer = null;
3557
+ const timeout = new Promise((_, reject) => {
3558
+ timer = setTimeout(() => reject(flushError("Save timed out.", "failed")), Math.max(0, timeoutMs));
3559
+ });
3560
+ try {
3561
+ return await Promise.race([flushBytes(keepalive, deadline), timeout]);
3562
+ } catch (err) {
3563
+ if (err && typeof err.state === "string") throw err;
3564
+ throw flushError(err?.message || "Save failed", "failed");
3565
+ } finally {
3566
+ clearTimeout(timer);
3567
+ }
3568
+ }
3569
+ async function flushBytes(keepalive, deadline) {
3570
+ if (!baselineSettled()) await nextEvent("clay:baseline-settled", deadline);
3571
+ let saved = false;
3572
+ let etag = null;
3573
+ for (let attempt = 0; attempt < 4; attempt++) {
3574
+ if (isSaveInProgress()) await settled(deadline);
3575
+ if (isSaveConflicted()) throw flushError("This document has a conflict to resolve.", "conflict");
3576
+ const token = gateCaptureToken();
3577
+ if (!bytesUnsaved()) {
3578
+ gateClearIfUnchanged(token);
3579
+ if (hasUnsavedState()) throw flushError("This document has unsaved work a save cannot write.", "blocked");
3580
+ return { state: saved ? "saved" : "clean", etag: etag ?? lastSeenEtag() };
3581
+ }
3582
+ const result2 = await withKeepalive(keepalive, () => savePage());
3583
+ if (result2.msgType === "skipped") continue;
3584
+ if (!result2.ok) throw flushError(result2.msg || "Save failed", result2.msgType === "conflict" || isSaveConflicted() ? "conflict" : "failed");
3585
+ saved = true;
3586
+ etag = result2.etag ?? null;
3587
+ }
3588
+ throw flushError("The document kept changing while saving.", "failed");
3589
+ }
3369
3590
  async function saveOverwritingConflict(callback = () => {
3370
3591
  }) {
3371
3592
  await seedEtag({ fresh: true });
@@ -3391,7 +3612,7 @@
3391
3612
  function initBaselineCapture() {
3392
3613
  if (!isEditMode) return;
3393
3614
  let userEdited = false;
3394
- let settled = false;
3615
+ let settled2 = false;
3395
3616
  let unsubscribeMutation = null;
3396
3617
  const { forComparison: immediateContents, forDirty: immediateDirty } = captureForComparisonAndDirty();
3397
3618
  lastSavedContents = immediateContents;
@@ -3406,8 +3627,8 @@
3406
3627
  };
3407
3628
  userEditEvents.forEach((evt) => document.addEventListener(evt, markUserEdited, true));
3408
3629
  const captureBaseline = () => {
3409
- if (settled) return;
3410
- settled = true;
3630
+ if (settled2) return;
3631
+ settled2 = true;
3411
3632
  if (unsubscribeMutation) unsubscribeMutation();
3412
3633
  userEditEvents.forEach((evt) => document.removeEventListener(evt, markUserEdited, true));
3413
3634
  if (!userEdited && lastSavedContents === immediateContents && lastSavedDirty === immediateDirty) {
@@ -3431,7 +3652,7 @@
3431
3652
  captureBaseline
3432
3653
  );
3433
3654
  setTimeout(() => {
3434
- if (!settled) captureBaseline();
3655
+ if (!settled2) captureBaseline();
3435
3656
  }, MAX_SETTLE_MS);
3436
3657
  }
3437
3658
  function savePageThrottled(callback = () => {
@@ -3508,10 +3729,12 @@
3508
3729
  init_snapshot();
3509
3730
  init_etag();
3510
3731
  init_dirty_gate();
3732
+ init_unsaved_state();
3511
3733
  init_autosave_state();
3512
3734
  init_root_attrs();
3513
3735
  init_autosave_debug();
3514
3736
  init_user_gesture();
3737
+ init_unsaved_warning();
3515
3738
  addDocumentTransform((clone) => {
3516
3739
  for (const name of ROOT_LIBRARY_ATTRS) clone.removeAttribute(name);
3517
3740
  });
@@ -3965,98 +4188,6 @@
3965
4188
  }
3966
4189
  });
3967
4190
 
3968
- // src/lib/unsaved-state.js
3969
- function registerUnsavedState({ id = "", isPending: isPending2 }) {
3970
- const key2 = ++nextKey;
3971
- sources.set(key2, { id, isPending: isPending2 });
3972
- let disposed = false;
3973
- return {
3974
- changed() {
3975
- if (disposed) return;
3976
- document.dispatchEvent(new CustomEvent("clay:unsaved-state-changed", {
3977
- detail: { pending: pendingIds() }
3978
- }));
3979
- },
3980
- dispose() {
3981
- if (disposed) return;
3982
- disposed = true;
3983
- sources.delete(key2);
3984
- }
3985
- };
3986
- }
3987
- function isPending(source2) {
3988
- try {
3989
- return !!source2.isPending();
3990
- } catch (err) {
3991
- console.error(`[clay] unsaved-state check "${source2.id}" threw`, err);
3992
- return true;
3993
- }
3994
- }
3995
- function pendingIds() {
3996
- return [...sources.values()].filter(isPending).map((s2) => s2.id);
3997
- }
3998
- function hasUnsavedState() {
3999
- for (const source2 of sources.values()) if (isPending(source2)) return true;
4000
- return false;
4001
- }
4002
- var sources, nextKey;
4003
- var init_unsaved_state = __esm({
4004
- "src/lib/unsaved-state.js"() {
4005
- sources = /* @__PURE__ */ new Map();
4006
- nextKey = 0;
4007
- if (typeof window !== "undefined") {
4008
- window.clay = window.clay || {};
4009
- window.clay.registerUnsavedState = registerUnsavedState;
4010
- }
4011
- }
4012
- });
4013
-
4014
- // src/core/unsaved-warning.js
4015
- var unsaved_warning_exports = {};
4016
- __export(unsaved_warning_exports, {
4017
- reloadAfterDiscard: () => reloadAfterDiscard
4018
- });
4019
- function reloadAfterDiscard({ isCurrent, reload }) {
4020
- if (!isCurrent()) return false;
4021
- discardPermit = true;
4022
- setTimeout(() => {
4023
- discardPermit = false;
4024
- }, 1e3);
4025
- reload();
4026
- return true;
4027
- }
4028
- var discardPermit;
4029
- var init_unsaved_warning = __esm({
4030
- "src/core/unsaved-warning.js"() {
4031
- init_is_edit_mode();
4032
- init_snapshot();
4033
- init_save();
4034
- init_autosave_debug();
4035
- init_unsaved_state();
4036
- discardPermit = false;
4037
- preloadIfEnabled();
4038
- window.addEventListener("beforeunload", (event) => {
4039
- if (!isEditMode) return;
4040
- if (discardPermit) {
4041
- discardPermit = false;
4042
- return;
4043
- }
4044
- const held = hasUnsavedState();
4045
- let bytesDiffer = false;
4046
- if (!held && !window.clay?.demo) {
4047
- const currentForCompare = captureForDirtyCheck();
4048
- const lastSaved = getLastSavedDirty();
4049
- bytesDiffer = currentForCompare !== lastSaved;
4050
- if (bytesDiffer) logUnloadDiffSync(currentForCompare, lastSaved);
4051
- }
4052
- if (held || bytesDiffer) {
4053
- event.preventDefault();
4054
- event.returnValue = "";
4055
- }
4056
- });
4057
- }
4058
- });
4059
-
4060
4191
  // src/vendor/hyper-morph.vendor.js
4061
4192
  var hyper_morph_vendor_exports = {};
4062
4193
  __export(hyper_morph_vendor_exports, {
@@ -10603,10 +10734,10 @@
10603
10734
  }
10604
10735
  _flushQueuedSend() {
10605
10736
  if (this.isDestroyed || this._sendInFlight) return;
10606
- const queued = this._queuedSend;
10607
- if (!queued) return;
10737
+ const queued2 = this._queuedSend;
10738
+ if (!queued2) return;
10608
10739
  this._queuedSend = null;
10609
- this._postUpdate(queued.html, queued.identityMap, queued.etag);
10740
+ this._postUpdate(queued2.html, queued2.identityMap, queued2.etag);
10610
10741
  }
10611
10742
  /**
10612
10743
  * Tell the other editors what this tab's save stored, and under which stamp.
@@ -10660,10 +10791,10 @@
10660
10791
  retry = true;
10661
10792
  }).finally(() => {
10662
10793
  this._sendInFlight = false;
10663
- const queued = this._queuedSend;
10794
+ const queued2 = this._queuedSend;
10664
10795
  this._queuedSend = null;
10665
- if (queued) {
10666
- this._postUpdate(queued.html, queued.identityMap, queued.etag);
10796
+ if (queued2) {
10797
+ this._postUpdate(queued2.html, queued2.identityMap, queued2.etag);
10667
10798
  return;
10668
10799
  }
10669
10800
  if (!retry || !etag || attempt >= 3 || this.isDestroyed) return;
@@ -25244,10 +25375,10 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
25244
25375
  );
25245
25376
  return canvas;
25246
25377
  }
25247
- let settled = false;
25378
+ let settled2 = false;
25248
25379
  const finishOnce = () => {
25249
- if (settled) return false;
25250
- settled = true;
25380
+ if (settled2) return false;
25381
+ settled2 = true;
25251
25382
  host.close();
25252
25383
  URL.revokeObjectURL(url);
25253
25384
  active2 = false;
@@ -25257,7 +25388,7 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
25257
25388
  content: stage,
25258
25389
  confirmLabel,
25259
25390
  onConfirm() {
25260
- if (settled) return;
25391
+ if (settled2) return;
25261
25392
  let canvas, dataURL;
25262
25393
  try {
25263
25394
  canvas = renderCrop();
@@ -25606,10 +25737,10 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
25606
25737
  const path = token ? `${UPLOAD_PATH}/${token}` : UPLOAD_PATH;
25607
25738
  const url = new URL(path, window.location.origin).href;
25608
25739
  const xhr = new XMLHttpRequest();
25609
- let settled = false;
25740
+ let settled2 = false;
25610
25741
  const finish2 = (value) => {
25611
- if (settled) return;
25612
- settled = true;
25742
+ if (settled2) return;
25743
+ settled2 = true;
25613
25744
  clearTimeout(timeoutId);
25614
25745
  if (signal) signal.removeEventListener("abort", onAbort);
25615
25746
  resolve(value);
@@ -25779,10 +25910,10 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
25779
25910
  stream.onerror = () => {
25780
25911
  };
25781
25912
  streamReady = new Promise((resolve) => {
25782
- let settled = false;
25913
+ let settled2 = false;
25783
25914
  const finish2 = () => {
25784
- if (settled) return;
25785
- settled = true;
25915
+ if (settled2) return;
25916
+ settled2 = true;
25786
25917
  resolve();
25787
25918
  };
25788
25919
  stream.onopen = finish2;
@@ -25957,7 +26088,7 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
25957
26088
  setTimeout(settle3, timeout);
25958
26089
  });
25959
26090
  }
25960
- async function flushSave() {
26091
+ async function flushSave2() {
25961
26092
  if (!isEditMode) return;
25962
26093
  const clay4 = window.clay;
25963
26094
  if (!clay4 || typeof clay4.save !== "function") return;
@@ -26076,7 +26207,7 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
26076
26207
  if (rec.document === "edit") {
26077
26208
  await holdSaving(rec);
26078
26209
  if (rec.state !== "sent") return;
26079
- await flushSave();
26210
+ await flushSave2();
26080
26211
  if (rec.state !== "sent") return;
26081
26212
  }
26082
26213
  await openStream();
@@ -36080,10 +36211,10 @@ ${t}` : `"use strict"; return (${t});`;
36080
36211
  onYes = [];
36081
36212
  onNo = [];
36082
36213
  onOpen = [];
36083
- let settled = false;
36214
+ let settled2 = false;
36084
36215
  const dismiss3 = () => {
36085
- if (settled) return;
36086
- settled = true;
36216
+ if (settled2) return;
36217
+ settled2 = true;
36087
36218
  myOnNo.forEach((cb) => cb());
36088
36219
  };
36089
36220
  if (this.isShowing || document.querySelector("[data-clay-modal]")) {
@@ -36149,7 +36280,7 @@ ${t}` : `"use strict"; return (${t});`;
36149
36280
  }
36150
36281
  }
36151
36282
  if (shouldClose) {
36152
- settled = true;
36283
+ settled2 = true;
36153
36284
  MicroModal.close();
36154
36285
  }
36155
36286
  }
@@ -37110,8 +37241,10 @@ ${t}` : `"use strict"; return (${t});`;
37110
37241
  const saveFn = save.savePage || save.default;
37111
37242
  saveFn.force = save.savePageForce;
37112
37243
  saveFn.overwrite = save.saveOverwritingConflict;
37244
+ saveFn.flush = save.flushSave;
37113
37245
  clay4.save = saveFn;
37114
37246
  }
37247
+ clay4.hasUnsavedChanges = loaded["core/unsaved-warning.js"]?.hasUnsavedChanges ?? (() => false);
37115
37248
  if (snapshot) {
37116
37249
  clay4.getHTML = snapshot.getPageContents;
37117
37250
  clay4.addDocumentTransform = snapshot.addDocumentTransform;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT-0",
@@ -25,6 +25,12 @@ import {
25
25
  let saveInProgress = false;
26
26
  const SAVE_PATH = '/_/save';
27
27
  const SAVE_TIMEOUT_MS = 12000;
28
+ // The browser caps a keepalive body (Chromium refuses one over 64 KB), and it counts
29
+ // BYTES: the hint is only attached to a save whose UTF-8 encoding fits well inside that.
30
+ const KEEPALIVE_MAX_BODY = 60000;
31
+ // Set by `withKeepalive`, read once by the next `sendOnce`: the hint belongs to one
32
+ // request, not to the save pipeline.
33
+ let nextSaveKeepalive = false;
28
34
 
29
35
  // Listeners for "the host took these exact bytes". This is the only place in the
30
36
  // library that knows both the body that went out and that the host accepted it, and
@@ -37,6 +43,26 @@ const SAVE_TIMEOUT_MS = 12000;
37
43
  // CustomEvent would put it on a bus any script on the page can listen to.
38
44
  const saveAcceptedHooks = [];
39
45
 
46
+ /**
47
+ * Send the next save with `keepalive`, so a request whose page is going away (a tab
48
+ * closing, a navigation) is still finished by the transport.
49
+ *
50
+ * A transport hint and nothing more: the save is the same save, and the body has to
51
+ * fit the browser's keepalive cap for the hint to go on the wire at all.
52
+ *
53
+ * @param {boolean} keepalive
54
+ * @param {Function} run - the save to run with the hint set
55
+ * @returns {Promise<Object>} whatever `run` resolves with
56
+ */
57
+ export async function withKeepalive(keepalive, run) {
58
+ nextSaveKeepalive = Boolean(keepalive);
59
+ try {
60
+ return await run();
61
+ } finally {
62
+ nextSaveKeepalive = false;
63
+ }
64
+ }
65
+
40
66
  /**
41
67
  * Check if a save is currently in progress.
42
68
  * @returns {boolean}
@@ -258,9 +284,10 @@ function skippedResult(msg) {
258
284
  * @param {AbortSignal} signal
259
285
  * @param {string} saveId - this attempt's §6 receipt id
260
286
  * @param {?string} etag - the stamp to send as If-Match, or null
287
+ * @param {boolean} keepalive - whether to ask the transport to see the request through
261
288
  * @returns {{url: string, options: Object}}
262
289
  */
263
- function buildSaveRequest(content, userDriven, signal, saveId, etag) {
290
+ function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive = false) {
264
291
  const token = saveToken();
265
292
  const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
266
293
  const options = {
@@ -287,6 +314,8 @@ function buildSaveRequest(content, userDriven, signal, saveId, etag) {
287
314
 
288
315
  if (etag) options.headers['If-Match'] = etag;
289
316
 
317
+ if (keepalive && new Blob([content]).size < KEEPALIVE_MAX_BODY) options.keepalive = true;
318
+
290
319
  return { url: resolveSaveUrl(path), options };
291
320
  }
292
321
 
@@ -357,7 +386,11 @@ function sendOnce(content, saveId, etag) {
357
386
  const gestureDriven = consumeUserDriven();
358
387
  const explicitlyAsked = consumeExplicitSave();
359
388
  const userDriven = gestureDriven || explicitlyAsked;
360
- const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag);
389
+ // Read-and-reset here too, at the one send that can still honour it: a save that
390
+ // returns early never reaches this point, so the hint cannot leak onto a later one.
391
+ const keepalive = nextSaveKeepalive;
392
+ nextSaveKeepalive = false;
393
+ const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag, keepalive);
361
394
  rememberSentId(saveId);
362
395
 
363
396
  return fetch(url, options)
package/src/core/save.js CHANGED
@@ -18,15 +18,22 @@ import {
18
18
  replacePageWith as replacePageWithCore,
19
19
  addDocumentTransform,
20
20
  isSaveInProgress,
21
- saveFateIsUnknown
21
+ saveFateIsUnknown,
22
+ withKeepalive
22
23
  } from "./save-core.js";
23
24
  import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
24
- import { seedEtag } from "./etag.js";
25
+ import { seedEtag, lastSeenEtag } from "./etag.js";
25
26
  import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
27
+ import { hasUnsavedState } from "../lib/unsaved-state.js";
26
28
  import { autosaveActive } from "../lib/autosave-state.js";
27
29
  import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
28
30
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
29
31
  import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
32
+ // A deliberate import cycle: unsaved-warning reads this module's saved baseline, and
33
+ // flush asks the SAME bytes question the close warning asks, through `bytesUnsaved`.
34
+ // Both read across it at call time, never while a module is evaluating, so neither
35
+ // side sees a half-built module.
36
+ import { bytesUnsaved } from "./unsaved-warning.js";
30
37
 
31
38
  // Keep this library's own root state out of the saved bytes.
32
39
  //
@@ -560,6 +567,101 @@ export function savePageForce(callback = () => {}) {
560
567
  });
561
568
  }
562
569
 
570
+ // Resolve on the next save that settles, or reject once the deadline has passed.
571
+ // Every settled state is one, not just success: a save that failed is an answer about
572
+ // the page's bytes too, and the caller decides what to do with it.
573
+ function settled(deadline) {
574
+ const events = ['clay:save-saved', 'clay:save-error', 'clay:save-offline', 'clay:save-conflict'];
575
+ return new Promise((resolve, reject) => {
576
+ let timer = null;
577
+ const cleanup = () => {
578
+ clearTimeout(timer);
579
+ for (const name of events) document.removeEventListener(name, onSettle);
580
+ };
581
+ const onSettle = () => {
582
+ cleanup();
583
+ resolve();
584
+ };
585
+ const remaining = deadline - Date.now();
586
+ if (remaining <= 0) {
587
+ reject(Object.assign(new Error('Save timed out.'), { state: 'failed' }));
588
+ return;
589
+ }
590
+ timer = setTimeout(() => {
591
+ cleanup();
592
+ reject(Object.assign(new Error('Save timed out.'), { state: 'failed' }));
593
+ }, remaining);
594
+ for (const name of events) document.addEventListener(name, onSettle);
595
+ });
596
+ }
597
+
598
+ function flushError(message, state) {
599
+ return Object.assign(new Error(message), { state });
600
+ }
601
+
602
+ // Resolve when `name` fires on document, or reject at the deadline.
603
+ function nextEvent(name, deadline) {
604
+ return new Promise((resolve, reject) => {
605
+ const remaining = deadline - Date.now();
606
+ if (remaining <= 0) return reject(flushError('Save timed out.', 'failed'));
607
+ const timer = setTimeout(() => {
608
+ document.removeEventListener(name, onEvent);
609
+ reject(flushError('Save timed out.', 'failed'));
610
+ }, remaining);
611
+ const onEvent = () => { clearTimeout(timer); resolve(); };
612
+ document.addEventListener(name, onEvent, { once: true });
613
+ });
614
+ }
615
+
616
+ // Save now; answer only when the host has accepted the bytes the page holds now.
617
+ // Resolves { state: 'view' | 'clean' | 'saved', etag }. Rejects with an Error carrying
618
+ // state 'failed' (including a timeout), 'conflict', or 'blocked' (the bytes are on the
619
+ // host but the page holds work a save cannot write, registered through
620
+ // clay.registerUnsavedState; only a person can resolve that). Never overwrites.
621
+ // `keepalive` is a transport hint honoured under 60 KB of UTF-8.
622
+ export async function flushSave({ keepalive = false, timeoutMs = 15000 } = {}) {
623
+ if (!isEditMode) return { state: 'view', etag: null };
624
+ const deadline = Date.now() + timeoutMs;
625
+ let timer = null;
626
+ const timeout = new Promise((_, reject) => {
627
+ timer = setTimeout(() => reject(flushError('Save timed out.', 'failed')), Math.max(0, timeoutMs));
628
+ });
629
+ try {
630
+ return await Promise.race([flushBytes(keepalive, deadline), timeout]);
631
+ } catch (err) {
632
+ if (err && typeof err.state === 'string') throw err;
633
+ throw flushError(err?.message || 'Save failed', 'failed');
634
+ } finally {
635
+ clearTimeout(timer);
636
+ }
637
+ }
638
+
639
+ async function flushBytes(keepalive, deadline) {
640
+ // Load-time module churn is not the person's work: autosave never saves it, and
641
+ // neither does a flush. Wait for the baseline to settle (at most MAX_SETTLE_MS).
642
+ if (!baselineSettled()) await nextEvent('clay:baseline-settled', deadline);
643
+
644
+ let saved = false;
645
+ let etag = null;
646
+ for (let attempt = 0; attempt < 4; attempt++) {
647
+ if (isSaveInProgress()) await settled(deadline);
648
+ if (isSaveConflicted()) throw flushError('This document has a conflict to resolve.', 'conflict');
649
+ const token = gateCaptureToken();
650
+ if (!bytesUnsaved()) {
651
+ gateClearIfUnchanged(token);
652
+ if (hasUnsavedState()) throw flushError('This document has unsaved work a save cannot write.', 'blocked');
653
+ return { state: saved ? 'saved' : 'clean', etag: etag ?? lastSeenEtag() };
654
+ }
655
+ const result = await withKeepalive(keepalive, () => savePage());
656
+ // The lane was busy (a drained pending save is on the wire): wait it out next turn.
657
+ if (result.msgType === 'skipped') continue;
658
+ if (!result.ok) throw flushError(result.msg || 'Save failed', result.msgType === 'conflict' || isSaveConflicted() ? 'conflict' : 'failed');
659
+ saved = true;
660
+ etag = result.etag ?? null;
661
+ }
662
+ throw flushError('The document kept changing while saving.', 'failed');
663
+ }
664
+
563
665
  /**
564
666
  * Keep this tab's version, over the one the host is holding.
565
667
  *
@@ -43,6 +43,39 @@ preloadIfEnabled();
43
43
  // sandboxed document, which cannot read cookies at all. Those are exactly the
44
44
  // documents where an unsaved edit is easiest to lose. If the page is editable,
45
45
  // the person editing it deserves the warning.
46
+ //
47
+ // The same predicate is exposed as `clay.hasUnsavedChanges`, which a host asks
48
+ // synchronously before deciding whether a save is worth asking for.
49
+ export function hasUnsavedChanges() {
50
+ if (!isEditMode) return false;
51
+
52
+ // Work outside the DOM first: it needs no capture, and a capture that throws
53
+ // must not hide it. The demo plugin does not save it either, so it warns there too.
54
+ if (hasUnsavedState()) return true;
55
+
56
+ // The demo plugin saves the page's bytes into this browser's own storage, so
57
+ // leaving loses none of them.
58
+ if (window.clay?.demo) return false;
59
+
60
+ return bytesUnsaved();
61
+ }
62
+
63
+ // The page's own bytes against the last bytes the host accepted. A capture that throws
64
+ // counts as unsaved: something on the page is broken, and "nothing to lose" is the one
65
+ // answer that can lose work.
66
+ //
67
+ // The DIRTY domain, not the autosave domain. An edit inside a no-trigger-autosave
68
+ // region never starts a save by itself, which is exactly why closing the tab on one has
69
+ // to warn: nothing else is going to write it.
70
+ export function bytesUnsaved() {
71
+ if (!isEditMode) return false;
72
+ try {
73
+ return captureForDirtyCheck() !== getLastSavedDirty();
74
+ } catch {
75
+ return true;
76
+ }
77
+ }
78
+
46
79
  window.addEventListener('beforeunload', (event) => {
47
80
  if (!isEditMode) return;
48
81
 
@@ -51,26 +84,16 @@ window.addEventListener('beforeunload', (event) => {
51
84
  return;
52
85
  }
53
86
 
54
- // Work outside the DOM first: it needs no capture, and a capture that throws
55
- // must not hide it. The demo plugin does not save it either, so it warns there too.
56
- const held = hasUnsavedState();
57
-
58
- // The demo plugin saves the page's bytes into this browser's own storage, so
59
- // leaving loses none of them.
60
- let bytesDiffer = false;
61
- if (!held && !window.clay?.demo) {
62
- // The DIRTY domain, not the autosave domain. An edit inside a
63
- // no-trigger-autosave region never starts a save by itself, which is exactly
64
- // why closing the tab on one has to warn: nothing else is going to write it.
65
- const currentForCompare = captureForDirtyCheck();
66
- const lastSaved = getLastSavedDirty();
67
- bytesDiffer = currentForCompare !== lastSaved;
68
- // Debug: log what's different before showing the warning
69
- if (bytesDiffer) logUnloadDiffSync(currentForCompare, lastSaved);
70
- }
87
+ if (!hasUnsavedChanges()) return;
71
88
 
72
- if (held || bytesDiffer) {
73
- event.preventDefault();
74
- event.returnValue = '';
89
+ event.preventDefault();
90
+ event.returnValue = '';
91
+ // Debug only, after the decision: a capture that throws here must not cancel the warning.
92
+ if (!window.clay?.demo && !hasUnsavedState()) {
93
+ try {
94
+ const currentForCompare = captureForDirtyCheck();
95
+ const lastSaved = getLastSavedDirty();
96
+ if (currentForCompare !== lastSaved) logUnloadDiffSync(currentForCompare, lastSaved);
97
+ } catch {}
75
98
  }
76
99
  });
@@ -35,6 +35,26 @@ let clearedAt = 0;
35
35
  let paused = false;
36
36
  let started = false;
37
37
 
38
+ // The state change, announced on the document: `clay:dirty` when work nothing has
39
+ // written appears, `clay:clean` when a save (or the oracle) has accounted for it. A
40
+ // burst of edits is ONE announcement, since what a listener wants is the state, not
41
+ // the edit. Only a transition is dispatched.
42
+ let announcedDirty = false;
43
+ let queued = false;
44
+
45
+ function scheduleAnnounce() {
46
+ if (!isEditMode || queued) return;
47
+ queued = true;
48
+ queueMicrotask(() => {
49
+ queued = false;
50
+ if (typeof document === 'undefined') return;
51
+ const dirty = pageMaybeDirty();
52
+ if (dirty === announcedDirty) return;
53
+ announcedDirty = dirty;
54
+ document.dispatchEvent(new CustomEvent(dirty ? 'clay:dirty' : 'clay:clean'));
55
+ });
56
+ }
57
+
38
58
  const PERSIST_CONTROLS = `input${PERSIST}, textarea${PERSIST}, select${PERSIST}`;
39
59
 
40
60
  // Regions the loss oracle never sees. The hub feed already skips them, through
@@ -81,6 +101,7 @@ function onUserInput(event) {
81
101
  if (!(el.matches('input, textarea, select') || el.isContentEditable)) return;
82
102
  if (el.closest(GATE_IGNORE)) return;
83
103
  changes++;
104
+ scheduleAnnounce();
84
105
  }
85
106
 
86
107
  export function startDirtyGate() {
@@ -89,7 +110,10 @@ export function startDirtyGate() {
89
110
  Mutation.onAnyChange(
90
111
  { omitChangeDetails: true, require: 'dirty' },
91
112
  () => {
92
- if (!paused) changes++;
113
+ if (!paused) {
114
+ changes++;
115
+ scheduleAnnounce();
116
+ }
93
117
  }
94
118
  );
95
119
  document.addEventListener('input', onUserInput, true);
@@ -151,6 +175,7 @@ export function probeMarkClean() {
151
175
  for (const el of gatedPersistControls()) {
152
176
  probeCache.set(el, controlSignature(el));
153
177
  }
178
+ scheduleAnnounce();
154
179
  }
155
180
 
156
181
  export function pageMaybeDirty() {
@@ -173,6 +198,7 @@ export function gateCaptureToken() {
173
198
  /** A change nothing in the DOM recorded: the page holds text a save has not written. */
174
199
  export function gateMarkDirty() {
175
200
  changes++;
201
+ scheduleAnnounce();
176
202
  }
177
203
 
178
204
  export function gateClearIfUnchanged(token) {
@@ -186,6 +212,7 @@ export function gateClearIfUnchanged(token) {
186
212
  for (const [el, sig] of token.probe) {
187
213
  if (controlSignature(el) === sig) probeCache.set(el, sig);
188
214
  }
215
+ scheduleAnnounce();
189
216
  }
190
217
 
191
218
  if (typeof document !== 'undefined' && isEditMode) {
package/src/loader.js CHANGED
@@ -88,8 +88,14 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
88
88
  const saveFn = save.savePage || save.default;
89
89
  saveFn.force = save.savePageForce;
90
90
  saveFn.overwrite = save.saveOverwritingConflict;
91
+ saveFn.flush = save.flushSave;
91
92
  clay.save = saveFn;
92
93
  }
94
+ // The close warning's own predicate, reachable without a capture. It falls back to
95
+ // false rather than to absent: the module is edit-only, and a document that cannot
96
+ // save has nothing unsaved. Older clayjs has no such member, and that is the answer
97
+ // a host feature-detects.
98
+ clay.hasUnsavedChanges = loaded["core/unsaved-warning.js"]?.hasUnsavedChanges ?? (() => false);
93
99
  if (snapshot) {
94
100
  clay.getHTML = snapshot.getPageContents;
95
101
  clay.addDocumentTransform = snapshot.addDocumentTransform;