@panphora/clayjs 1.5.1 → 1.5.3

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.5.1 standalone build: every clayjs module in one file. https://clayjs.com/offline
1
+ /* clayjs 1.5.3 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;
@@ -541,6 +541,7 @@
541
541
  return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
542
542
  }
543
543
  function markGestureTurn() {
544
+ anyGesture = true;
544
545
  gestureTaskActive = true;
545
546
  lastTrustedGestureTs = now();
546
547
  setTimeout(() => {
@@ -565,6 +566,9 @@
565
566
  function markUserDriven() {
566
567
  userDrivenSinceLastSave = true;
567
568
  }
569
+ function gestureSeen() {
570
+ return anyGesture;
571
+ }
568
572
  function consumeUserDriven() {
569
573
  const v2 = userDrivenSinceLastSave;
570
574
  userDrivenSinceLastSave = false;
@@ -581,13 +585,14 @@
581
585
  function clearExplicitSave() {
582
586
  explicitSaveIntent = false;
583
587
  }
584
- var GESTURE_EVENTS, RECENT_GESTURE_MS, gestureTaskActive, lastTrustedGestureTs, userDrivenSinceLastSave, explicitSaveIntent, installed;
588
+ var GESTURE_EVENTS, RECENT_GESTURE_MS, gestureTaskActive, lastTrustedGestureTs, anyGesture, userDrivenSinceLastSave, explicitSaveIntent, installed;
585
589
  var init_user_gesture = __esm({
586
590
  "src/lib/user-gesture.js"() {
587
591
  GESTURE_EVENTS = ["pointerdown", "pointerup", "click", "keydown", "beforeinput", "change", "submit", "paste", "drop", "cut"];
588
592
  RECENT_GESTURE_MS = 500;
589
593
  gestureTaskActive = false;
590
594
  lastTrustedGestureTs = -Infinity;
595
+ anyGesture = false;
591
596
  userDrivenSinceLastSave = false;
592
597
  explicitSaveIntent = false;
593
598
  installed = false;
@@ -1555,7 +1560,7 @@
1555
1560
  const clone = captureSnapshot({ flushUndo });
1556
1561
  if (emitForSync) {
1557
1562
  document.dispatchEvent(new CustomEvent("clay:snapshot-ready", {
1558
- detail: { documentElement: clone }
1563
+ detail: { documentElement: clone, forSave: true }
1559
1564
  }));
1560
1565
  }
1561
1566
  runAuthoredHandlers(clone, "onbeforesave");
@@ -1898,7 +1903,7 @@
1898
1903
  async function seedEtag({ fresh = false, clearIfMissing = fresh } = {}) {
1899
1904
  const at3 = generation;
1900
1905
  const meta = await hostMeta({ fresh });
1901
- conditional = meta.extensions.includes("conditional");
1906
+ if (typeof meta.spec === "number") conditional = meta.extensions.includes("conditional");
1902
1907
  if (generation !== at3) return lastSeen;
1903
1908
  const seed = meta.document?.etag;
1904
1909
  if (typeof seed === "string" && seed !== "") {
@@ -2005,6 +2010,10 @@
2005
2010
  // A refusal that may be answering this tab's own timed-out write. Only the
2006
2011
  // notice uses it, and only to word itself; nothing decides anything on it.
2007
2012
  afterTimeout: conflicted && unknownAttempt !== null,
2013
+ // The stamp the host refused this save against: the version that beat it.
2014
+ // Live sync compares it with the frames it merges to know when the refusal
2015
+ // is answered.
2016
+ conflictEtag: conflicted ? err.etag ?? null : null,
2008
2017
  etag: null
2009
2018
  };
2010
2019
  }
@@ -2377,6 +2386,20 @@
2377
2386
  }
2378
2387
  });
2379
2388
 
2389
+ // src/lib/autosave-state.js
2390
+ function setAutosaveActive(value) {
2391
+ active = !!value;
2392
+ }
2393
+ function autosaveActive() {
2394
+ return active;
2395
+ }
2396
+ var active;
2397
+ var init_autosave_state = __esm({
2398
+ "src/lib/autosave-state.js"() {
2399
+ active = false;
2400
+ }
2401
+ });
2402
+
2380
2403
  // src/lib/autosave-debug.js
2381
2404
  function isDebugEnabled() {
2382
2405
  try {
@@ -2479,6 +2502,8 @@
2479
2502
  var save_exports = {};
2480
2503
  __export(save_exports, {
2481
2504
  addDocumentTransform: () => addDocumentTransform,
2505
+ baselineSettled: () => baselineSettled,
2506
+ conflictResolvedBySync: () => conflictResolvedBySync,
2482
2507
  default: () => save_default,
2483
2508
  getLastSavedBytes: () => getLastSavedBytes,
2484
2509
  getLastSavedContents: () => getLastSavedContents,
@@ -2533,7 +2558,8 @@
2533
2558
  autosaveMissed = false;
2534
2559
  savePageThrottled();
2535
2560
  }
2536
- function holdForConflict() {
2561
+ function holdForConflict(etag) {
2562
+ conflictEtag = etag ?? null;
2537
2563
  if (conflictHold) return;
2538
2564
  conflictHold = true;
2539
2565
  suspendAutosave();
@@ -2541,11 +2567,28 @@
2541
2567
  function releaseConflictHold() {
2542
2568
  if (!conflictHold) return;
2543
2569
  conflictHold = false;
2570
+ conflictEtag = null;
2544
2571
  resumeAutosave();
2545
2572
  }
2546
2573
  function isSaveConflicted() {
2547
2574
  return conflictHold;
2548
2575
  }
2576
+ function conflictResolvedBySync(etag) {
2577
+ if (!conflictHold) return;
2578
+ if (conflictEtag && etag !== conflictEtag) return;
2579
+ conflictHold = false;
2580
+ conflictEtag = null;
2581
+ const awaitingManualSave = !autosaveActive() && pageMaybeDirty();
2582
+ if (!awaitingManualSave) {
2583
+ if (document.documentElement.getAttribute("savestatus") === "conflict") {
2584
+ document.documentElement.setAttribute("savestatus", "saved");
2585
+ }
2586
+ document.dispatchEvent(new CustomEvent("clay:save-conflict-resolved", {
2587
+ detail: { timestamp: Date.now() }
2588
+ }));
2589
+ }
2590
+ resumeAutosave();
2591
+ }
2549
2592
  function skipped_(msg) {
2550
2593
  return { ok: false, msg, msgType: "skipped", code: null, etag: null };
2551
2594
  }
@@ -2560,10 +2603,11 @@
2560
2603
  logBaseline(label, `${lastSavedContents.length} chars`);
2561
2604
  releaseConflictHold();
2562
2605
  } else if (result2.msgType === "conflict") {
2563
- holdForConflict();
2606
+ holdForConflict(result2.conflictEtag);
2564
2607
  setSaveState("conflict", result2.msg, result2.msgType, {
2565
2608
  changedBy: result2.changedBy ?? null,
2566
- afterTimeout: result2.afterTimeout === true
2609
+ afterTimeout: result2.afterTimeout === true,
2610
+ etag: result2.conflictEtag ?? null
2567
2611
  });
2568
2612
  } else if (result2.msgType !== "skipped") {
2569
2613
  if (!navigator.onLine) {
@@ -2716,6 +2760,9 @@
2716
2760
  }
2717
2761
  });
2718
2762
  }
2763
+ function baselineSettled() {
2764
+ return baselineSettledAt;
2765
+ }
2719
2766
  function initBaselineCapture() {
2720
2767
  if (!isEditMode) return;
2721
2768
  let userEdited = false;
@@ -2751,6 +2798,8 @@
2751
2798
  }
2752
2799
  baselineActive = false;
2753
2800
  document.documentElement.setAttribute("savestatus", "saved");
2801
+ baselineSettledAt = true;
2802
+ document.dispatchEvent(new CustomEvent("clay:baseline-settled"));
2754
2803
  };
2755
2804
  unsubscribeMutation = mutation_default.onAnyChange(
2756
2805
  { debounce: SETTLE_MS, omitChangeDetails: true, require: "autosave" },
@@ -2818,7 +2867,7 @@
2818
2867
  initSaveKeyboardShortcut();
2819
2868
  initHyperclaySaveButton();
2820
2869
  }
2821
- var savingTimeout, unsavedChanges, lastSavedContents, lastSavedDirty, pendingSave, autosaveSuspended, autosaveMissed, conflictHold, lastSavedBytes, throttledSave, baselineContents, baselineActive, SETTLE_MS, MAX_SETTLE_MS, save_default;
2870
+ var savingTimeout, unsavedChanges, lastSavedContents, lastSavedDirty, pendingSave, autosaveSuspended, autosaveMissed, conflictHold, conflictEtag, lastSavedBytes, throttledSave, baselineContents, baselineActive, baselineSettledAt, SETTLE_MS, MAX_SETTLE_MS, save_default;
2822
2871
  var init_save = __esm({
2823
2872
  "src/core/save.js"() {
2824
2873
  init_throttle();
@@ -2828,6 +2877,7 @@
2828
2877
  init_snapshot();
2829
2878
  init_etag();
2830
2879
  init_dirty_gate();
2880
+ init_autosave_state();
2831
2881
  init_root_attrs();
2832
2882
  init_autosave_debug();
2833
2883
  init_user_gesture();
@@ -2856,10 +2906,12 @@
2856
2906
  autosaveSuspended = 0;
2857
2907
  autosaveMissed = false;
2858
2908
  conflictHold = false;
2909
+ conflictEtag = null;
2859
2910
  lastSavedBytes = null;
2860
2911
  throttledSave = throttle_default(savePage, 1200);
2861
2912
  baselineContents = "";
2862
2913
  baselineActive = true;
2914
+ baselineSettledAt = false;
2863
2915
  SETTLE_MS = 500;
2864
2916
  MAX_SETTLE_MS = 3e3;
2865
2917
  if (document.readyState === "loading") {
@@ -3012,6 +3064,7 @@
3012
3064
  if (!isEditMode) return;
3013
3065
  document.addEventListener("clay:save-conflict", show);
3014
3066
  document.addEventListener("clay:save-saved", hide);
3067
+ document.addEventListener("clay:save-conflict-resolved", hide);
3015
3068
  document.addEventListener("keydown", onKeydown);
3016
3069
  }
3017
3070
  var BG2, INK2, EDGE2, WARN, FONT2, SOURCES, ARM_MS, root2, line, keep, drop, armTimer, armed, busy, still;
@@ -3369,20 +3422,6 @@
3369
3422
  }
3370
3423
  });
3371
3424
 
3372
- // src/lib/autosave-state.js
3373
- function setAutosaveActive(value) {
3374
- active = !!value;
3375
- }
3376
- function autosaveActive() {
3377
- return active;
3378
- }
3379
- var active;
3380
- var init_autosave_state = __esm({
3381
- "src/lib/autosave-state.js"() {
3382
- active = false;
3383
- }
3384
- });
3385
-
3386
3425
  // src/core/autosave.js
3387
3426
  var autosave_exports = {};
3388
3427
  __export(autosave_exports, {
@@ -12156,6 +12195,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12156
12195
  init_host_meta();
12157
12196
  init_etag();
12158
12197
  init_dirty_gate();
12198
+ init_user_gesture();
12159
12199
  init_stream();
12160
12200
  init_save();
12161
12201
  ({ createIdentityStore } = HyperMorph);
@@ -12222,8 +12262,6 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12222
12262
  this._conflictTickets = /* @__PURE__ */ new WeakMap();
12223
12263
  this.clientId = this.generateClientId();
12224
12264
  this.resumeId = null;
12225
- this.debounceMs = 150;
12226
- this.debounceTimer = null;
12227
12265
  this._savedSnapshot = null;
12228
12266
  this._sendInFlight = false;
12229
12267
  this._queuedSend = null;
@@ -12248,6 +12286,8 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12248
12286
  this._lastExternalSeq = 0;
12249
12287
  this._saveEpoch = 0;
12250
12288
  this._saveSavedHandler = null;
12289
+ this._saveConflictHandler = null;
12290
+ this._settledHandler = null;
12251
12291
  this._lastIdentityMap = null;
12252
12292
  this._applyGen = 0;
12253
12293
  this._holdRetryPeer = null;
@@ -12295,6 +12335,18 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12295
12335
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
12296
12336
  }
12297
12337
  }
12338
+ /**
12339
+ * Seed both merge bases (the peer lane's and the disk lane's) with the page as
12340
+ * it stands, so a first frame that arrives after local edits merges instead of
12341
+ * holding. A page already dirty has no trustworthy base; it keeps null and holds.
12342
+ */
12343
+ _seedBases() {
12344
+ if (pageMaybeDirty()) return;
12345
+ const clone = captureSnapshot({ flushUndo: false });
12346
+ this.lastHtml = serializeForSync(clone);
12347
+ this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
12348
+ this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
12349
+ }
12298
12350
  /**
12299
12351
  * Start the LiveSync system
12300
12352
  * Can be called after stop() to restart with a new file
@@ -12321,14 +12373,32 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12321
12373
  this._lastExternalSeq = 0;
12322
12374
  this._pendingExternal = null;
12323
12375
  this.resumeId = this.generateResumeId();
12324
- if (this.lane === "live" && !pageMaybeDirty()) {
12325
- const clone = captureSnapshot({ flushUndo: false });
12326
- this.lastHtml = serializeForSync(clone);
12327
- this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
12328
- this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
12329
- }
12376
+ if (this.lane === "live") this._seedBases();
12330
12377
  console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
12331
12378
  const gen = ++this._startGen;
12379
+ if (this.lane === "live" && !baselineSettled()) {
12380
+ const applyGen = this._applyGen;
12381
+ const saveEpoch = this._saveEpoch;
12382
+ const startHtml = this.lastHtml;
12383
+ const startDiskTicket = this._diskBaseTicket;
12384
+ const startDiskSeeded = this._diskBase !== null;
12385
+ this._settledHandler = () => {
12386
+ this._settledHandler = null;
12387
+ if (this.isDestroyed || this._startGen !== gen) return;
12388
+ if (gestureSeen()) return;
12389
+ if (pageMaybeDirty()) return;
12390
+ if (this._saveTicket !== 0) return;
12391
+ if (startHtml !== null && this._applyGen === applyGen && this._saveEpoch === saveEpoch && this.lastHtml === startHtml) {
12392
+ const clone = captureSnapshot({ flushUndo: false });
12393
+ this.lastHtml = serializeForSync(clone);
12394
+ this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
12395
+ }
12396
+ if (startDiskSeeded && this._diskBaseTicket === startDiskTicket) {
12397
+ this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
12398
+ }
12399
+ };
12400
+ document.addEventListener("clay:baseline-settled", this._settledHandler, { once: true });
12401
+ }
12332
12402
  this._ready = this._resolveProfile().then(() => {
12333
12403
  if (this.isDestroyed || this._startGen !== gen) return;
12334
12404
  this.connect();
@@ -12348,6 +12418,18 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12348
12418
  this._relayCommit();
12349
12419
  };
12350
12420
  document.addEventListener("clay:save-saved", this._saveSavedHandler);
12421
+ this._saveConflictHandler = (event) => {
12422
+ const refusedBy = event.detail && event.detail.etag;
12423
+ if (!refusedBy) return;
12424
+ queueMicrotask(() => {
12425
+ if (this.isDestroyed) return;
12426
+ if (refusedBy !== lastSeenEtag()) return;
12427
+ if (this.unresolvedConflicts.length) return;
12428
+ conflictResolvedBySync(refusedBy);
12429
+ this._saveAfterMerge(pageMaybeDirty(), false);
12430
+ });
12431
+ };
12432
+ document.addEventListener("clay:save-conflict", this._saveConflictHandler);
12351
12433
  }
12352
12434
  }
12353
12435
  /**
@@ -12368,7 +12450,14 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12368
12450
  document.removeEventListener("clay:save-saved", this._saveSavedHandler);
12369
12451
  this._saveSavedHandler = null;
12370
12452
  }
12371
- clearTimeout(this.debounceTimer);
12453
+ if (this._saveConflictHandler) {
12454
+ document.removeEventListener("clay:save-conflict", this._saveConflictHandler);
12455
+ this._saveConflictHandler = null;
12456
+ }
12457
+ if (this._settledHandler) {
12458
+ document.removeEventListener("clay:baseline-settled", this._settledHandler);
12459
+ this._settledHandler = null;
12460
+ }
12372
12461
  this._queuedSend = null;
12373
12462
  if (this._rafHandle != null) {
12374
12463
  this._cancelFrame(this._rafHandle);
@@ -12548,9 +12637,17 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12548
12637
  return;
12549
12638
  }
12550
12639
  const etag = typeof data.etag === "string" && data.etag ? data.etag : null;
12640
+ if (this.lane === "live" && !etag && conditionalSaves()) {
12641
+ this._log(`Dropping an unstamped live frame on a stamping host (seq=${seq})`);
12642
+ return;
12643
+ }
12551
12644
  if (etag && html === this.lastHtml) {
12552
12645
  this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
12553
12646
  recordEtag(etag);
12647
+ if (isSaveConflicted() && this.unresolvedConflicts.length === 0) {
12648
+ conflictResolvedBySync(etag);
12649
+ this._saveAfterMerge(pageMaybeDirty(), false);
12650
+ }
12554
12651
  return;
12555
12652
  }
12556
12653
  this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
@@ -12573,115 +12670,71 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12573
12670
  */
12574
12671
  listenForSnapshots() {
12575
12672
  this._snapshotHandler = (event) => {
12673
+ if (!event.detail || event.detail.forSave !== true) return;
12576
12674
  this._saveTicket = this._ticket();
12577
- if (this.isPaused) {
12578
- this._log("snapshot-ready received but isPaused, skipping");
12579
- return;
12580
- }
12581
12675
  const { documentElement: clone } = event.detail;
12582
12676
  if (!clone) return;
12583
12677
  this._log("snapshot-ready received, preparing to send");
12584
12678
  const html = serializeForSync(clone);
12585
12679
  const identityMap = this.identity.exportMap(clone, originalSnapshotNode);
12586
12680
  this._savedSnapshot = { html, identityMap };
12587
- this.sendUpdate(html, identityMap);
12588
12681
  };
12589
12682
  document.addEventListener("clay:snapshot-ready", this._snapshotHandler);
12590
12683
  }
12591
12684
  /**
12592
- * Send full HTML to the server (debounced, single-flight).
12685
+ * Send a landed save's HTML to the relay (single-flight).
12593
12686
  *
12594
12687
  * One POST on the wire at a time, with at most one newer payload queued; a
12595
12688
  * fresher snapshot replaces the queued one, because peers only ever need the
12596
12689
  * latest state. Two concurrent fetches could reach the server in either order,
12597
12690
  * and last-write-wins then stored the OLDER snapshot for every peer.
12598
12691
  */
12599
- sendUpdate(html, identityMap) {
12600
- clearTimeout(this.debounceTimer);
12601
- this.debounceTimer = setTimeout(() => {
12602
- this._enqueueSend(html, identityMap);
12603
- }, this.debounceMs);
12604
- }
12605
- _enqueueSend(html, identityMap) {
12692
+ _enqueueSend(html, identityMap, etag = null) {
12606
12693
  if (!this._profile) {
12607
- this._queuedSend = { html, identityMap };
12694
+ this._queuedSend = { html, identityMap, etag };
12608
12695
  this._resolveProfile().then(() => this._flushQueuedSend());
12609
12696
  return;
12610
12697
  }
12611
12698
  if (this._sendInFlight) {
12612
- this._queuedSend = { html, identityMap };
12699
+ this._queuedSend = { html, identityMap, etag };
12613
12700
  return;
12614
12701
  }
12615
- this._postUpdate(html, identityMap);
12702
+ this._postUpdate(html, identityMap, etag);
12616
12703
  }
12617
12704
  _flushQueuedSend() {
12618
12705
  if (this.isDestroyed || this._sendInFlight) return;
12619
12706
  const queued = this._queuedSend;
12620
12707
  if (!queued) return;
12621
12708
  this._queuedSend = null;
12622
- this._postUpdate(queued.html, queued.identityMap);
12709
+ this._postUpdate(queued.html, queued.identityMap, queued.etag);
12623
12710
  }
12624
12711
  /**
12625
12712
  * Tell the other editors what this tab's save stored, and under which stamp.
12626
12713
  *
12627
12714
  * Runs on clay:save-saved, by which point the save response has already been
12628
12715
  * recorded, so `lastSeenEtag()` is the stamp for the bytes that just landed.
12629
- *
12630
- * The content is `lastHtml`, the snapshot this tab most recently relayed, which
12631
- * is the state the save was taken from: the save pipeline dispatches
12632
- * clay:snapshot-ready on its way to capturing what to send, so that relay has
12633
- * already gone out. Re-sending those same bytes is not redundant, because the
12634
- * stamp is the new information and a stamp may never travel without the content
12635
- * it describes. A receiver whose baseline already matches takes the stamp and
12636
- * skips the morph.
12716
+ * The content is `_savedSnapshot`, captured by that save's own snapshot-ready
12717
+ * event, never `lastHtml`: a capture is not relayed until its save lands, so
12718
+ * peers only ever see versions the host accepted, each descending from the one
12719
+ * before. A host that returns no stamp still gets the relay, without one.
12637
12720
  */
12638
12721
  _relayCommit() {
12722
+ if (this.isDestroyed) return;
12639
12723
  const etag = lastSeenEtag();
12640
- if (!etag) return;
12641
- if (this.isDestroyed || this.isPaused) return;
12642
12724
  const pending2 = this._savedSnapshot;
12643
12725
  this._savedSnapshot = null;
12644
12726
  if (!pending2 || typeof pending2.html !== "string") return;
12645
- this._postCommit(pending2.html, etag, pending2.identityMap);
12727
+ this._enqueueSend(pending2.html, pending2.identityMap, etag || null);
12646
12728
  }
12647
- /**
12648
- * Post a snapshot whose point is the stamp attached to it.
12649
- *
12650
- * Deliberately not `_postUpdate`: that one returns early when the html matches
12651
- * `lastHtml`, and the whole point here is that the stamp is the new information
12652
- * even when the bytes are not. It also does not touch `lastHtml` or the
12653
- * single-flight queue, because it must not displace a real snapshot waiting to
12654
- * go out. It carries the identityMap captured with these bytes, so a receiver
12655
- * that morphs on this frame pairs elements the same way it would on the
12656
- * ordinary relay of the same content.
12657
- */
12658
- _postCommit(html, etag, identityMap) {
12659
- const profile = this._profile;
12660
- if (!profile) return;
12661
- fetch(new URL(profile.relayPath, window.location.origin).href, {
12662
- method: "POST",
12663
- headers: {
12664
- "Content-Type": "application/json",
12665
- [profile.documentHeader]: window.location.href
12666
- },
12667
- body: JSON.stringify({
12668
- [profile.snapshotKey]: html,
12669
- sender: this.clientId,
12670
- identityMap,
12671
- etag
12672
- })
12673
- }).catch((err) => {
12674
- this._log("Commit relay failed: " + (err && err.message));
12675
- });
12676
- }
12677
- _postUpdate(html, identityMap) {
12678
- if (html === this.lastHtml) {
12729
+ _postUpdate(html, identityMap, etag = null, attempt = 0) {
12730
+ if (!etag && html === this.lastHtml) {
12679
12731
  this._log("Skipping send - HTML unchanged");
12680
12732
  return;
12681
12733
  }
12682
12734
  this._log(`Sending update (HTML length: ${html.length}, lastHtml length: ${this.lastHtml?.length || 0})`);
12683
12735
  this._sendInFlight = true;
12684
12736
  const gen = this._applyGen;
12737
+ let retry = false;
12685
12738
  const profile = this._profile || WIRE_PROFILES.legacy;
12686
12739
  fetch(new URL(profile.relayPath, window.location.origin).href, {
12687
12740
  method: "POST",
@@ -12692,7 +12745,8 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12692
12745
  body: JSON.stringify({
12693
12746
  [profile.snapshotKey]: html,
12694
12747
  sender: this.clientId,
12695
- identityMap
12748
+ identityMap,
12749
+ ...etag ? { etag } : {}
12696
12750
  })
12697
12751
  }).then((response) => {
12698
12752
  if (response.ok) {
@@ -12704,15 +12758,30 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12704
12758
  }
12705
12759
  } else {
12706
12760
  console.warn("[LiveSync] Save returned status:", response.status);
12761
+ retry = response.status >= 500 || response.status === 408 || response.status === 429;
12707
12762
  }
12708
12763
  }).catch((err) => {
12709
12764
  console.error("[LiveSync] Save failed:", err);
12710
12765
  if (this.onError) this.onError(err);
12766
+ retry = true;
12711
12767
  }).finally(() => {
12712
12768
  this._sendInFlight = false;
12713
12769
  const queued = this._queuedSend;
12714
12770
  this._queuedSend = null;
12715
- if (queued) this._postUpdate(queued.html, queued.identityMap);
12771
+ if (queued) {
12772
+ this._postUpdate(queued.html, queued.identityMap, queued.etag);
12773
+ return;
12774
+ }
12775
+ if (!retry || !etag || attempt >= 3 || this.isDestroyed) return;
12776
+ this._sendInFlight = true;
12777
+ setTimeout(() => {
12778
+ this._sendInFlight = false;
12779
+ if (this.isDestroyed) return;
12780
+ const newer = this._queuedSend;
12781
+ this._queuedSend = null;
12782
+ if (newer) this._postUpdate(newer.html, newer.identityMap, newer.etag);
12783
+ else this._postUpdate(html, identityMap, etag, attempt + 1);
12784
+ }, 500 * 2 ** attempt);
12716
12785
  });
12717
12786
  }
12718
12787
  /**
@@ -13070,6 +13139,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13070
13139
  pauseGate();
13071
13140
  let diverged = false;
13072
13141
  let typedDuringWait = false;
13142
+ let stamped = false;
13073
13143
  try {
13074
13144
  if (this.lane === "live" && pageMaybeDirty() && this.lastHtml === null) {
13075
13145
  console.log("[LiveSync] Holding incoming update: unsaved local edits and no baseline to merge against");
@@ -13103,7 +13173,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13103
13173
  this.lastHtml = html;
13104
13174
  this._lastIdentityMap = identityMap && typeof identityMap === "object" && !Array.isArray(identityMap) ? identityMap : null;
13105
13175
  this._applyGen++;
13106
- if (typeof etag === "string" && etag) recordEtag(etag);
13176
+ if (typeof etag === "string" && etag) {
13177
+ recordEtag(etag);
13178
+ stamped = true;
13179
+ }
13107
13180
  if (this.lane === "live" && !diverged && !pageMaybeDirty() && this.unresolvedConflicts.length === 0) {
13108
13181
  const { forSave, forComparison, forDirty } = captureForSaveAndComparison({
13109
13182
  emitForSync: false,
@@ -13122,6 +13195,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13122
13195
  await new Promise((resolve) => setTimeout(resolve, 0));
13123
13196
  this.isPaused = false;
13124
13197
  }
13198
+ if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
13125
13199
  this._saveAfterMerge(diverged, typedDuringWait);
13126
13200
  }
13127
13201
  /**
@@ -13148,6 +13222,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13148
13222
  pauseGate();
13149
13223
  let diverged = false;
13150
13224
  let typedDuringWait = false;
13225
+ let stamped = false;
13151
13226
  try {
13152
13227
  if (this.lane === "live" && pageMaybeDirty() && this._diskBase === null) {
13153
13228
  console.log("[LiveSync] Holding external change: unsaved local edits and no baseline to merge against");
@@ -13173,7 +13248,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13173
13248
  typedDuringWait = typed;
13174
13249
  this._setDiskBase(html, ticket);
13175
13250
  window.scrollTo(scrollX, scrollY);
13176
- if (typeof etag === "string" && etag) recordEtag(etag);
13251
+ if (typeof etag === "string" && etag) {
13252
+ recordEtag(etag);
13253
+ stamped = true;
13254
+ }
13177
13255
  if (this.lane === "live" && !diverged && !pageMaybeDirty() && this.unresolvedConflicts.length === 0) {
13178
13256
  setLastSavedBaselines(...pairedBaseline());
13179
13257
  setUnsavedChanges(false);
@@ -13201,6 +13279,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13201
13279
  await new Promise((resolve) => setTimeout(resolve, 0));
13202
13280
  this.isPaused = false;
13203
13281
  }
13282
+ if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
13204
13283
  this._saveAfterMerge(diverged, typedDuringWait);
13205
13284
  }
13206
13285
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT-0",
package/src/core/etag.js CHANGED
@@ -82,7 +82,10 @@ export function forgetEtag() {
82
82
  export async function seedEtag({ fresh = false, clearIfMissing = fresh } = {}) {
83
83
  const at = generation;
84
84
  const meta = await hostMeta({ fresh });
85
- conditional = meta.extensions.includes("conditional");
85
+ // Only a real answer (spec is a number) says what the host supports: a refresh
86
+ // that failed comes back as a bare host with no extensions, and must not switch
87
+ // conditional saves off for the rest of the page's life.
88
+ if (typeof meta.spec === "number") conditional = meta.extensions.includes("conditional");
86
89
 
87
90
  if (generation !== at) return lastSeen;
88
91
 
@@ -185,6 +185,7 @@ function init() {
185
185
  if (!isEditMode) return;
186
186
  document.addEventListener("clay:save-conflict", show);
187
187
  document.addEventListener("clay:save-saved", hide);
188
+ document.addEventListener("clay:save-conflict-resolved", hide);
188
189
  document.addEventListener("keydown", onKeydown);
189
190
  }
190
191
 
@@ -208,6 +208,10 @@ function errorResult(err) {
208
208
  // A refusal that may be answering this tab's own timed-out write. Only the
209
209
  // notice uses it, and only to word itself; nothing decides anything on it.
210
210
  afterTimeout: conflicted && unknownAttempt !== null,
211
+ // The stamp the host refused this save against: the version that beat it.
212
+ // Live sync compares it with the frames it merges to know when the refusal
213
+ // is answered.
214
+ conflictEtag: conflicted ? (err.etag ?? null) : null,
211
215
  etag: null
212
216
  };
213
217
  }
package/src/core/save.js CHANGED
@@ -22,7 +22,8 @@ import {
22
22
  } from "./save-core.js";
23
23
  import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
24
24
  import { seedEtag } from "./etag.js";
25
- import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
25
+ import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
26
+ import { autosaveActive } from "../lib/autosave-state.js";
26
27
  import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
27
28
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
28
29
  import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
@@ -227,8 +228,10 @@ export function resumeAutosave() {
227
228
  // The hold is released when a save lands, whatever produced it: clay.save.overwrite,
228
229
  // a live-sync frame that brought the page back in step, or the other tab going away.
229
230
  let conflictHold = false;
231
+ let conflictEtag = null;
230
232
 
231
- function holdForConflict() {
233
+ function holdForConflict(etag) {
234
+ conflictEtag = etag ?? null;
232
235
  if (conflictHold) return;
233
236
  conflictHold = true;
234
237
  suspendAutosave();
@@ -237,6 +240,7 @@ function holdForConflict() {
237
240
  function releaseConflictHold() {
238
241
  if (!conflictHold) return;
239
242
  conflictHold = false;
243
+ conflictEtag = null;
240
244
  resumeAutosave();
241
245
  }
242
246
 
@@ -245,6 +249,33 @@ export function isSaveConflicted() {
245
249
  return conflictHold;
246
250
  }
247
251
 
252
+ /**
253
+ * Live sync merged the version the host refused this tab over, and the tab now
254
+ * holds that version's stamp, so the next save carries a stamp the host accepts.
255
+ * Only that version answers the refusal: a frame with any other stamp (an older
256
+ * save arriving late) leaves the hold alone. Autosave resumes, replaying one
257
+ * missed save. On a manual-save page with unsaved work the root stays in
258
+ * 'conflict' and the notice stays up until the person saves, since nothing has
259
+ * been written yet. Not clay:save-saved: no save happened, and that event runs
260
+ * every [onaftersave] handler.
261
+ */
262
+ export function conflictResolvedBySync(etag) {
263
+ if (!conflictHold) return;
264
+ if (conflictEtag && etag !== conflictEtag) return;
265
+ conflictHold = false;
266
+ conflictEtag = null;
267
+ const awaitingManualSave = !autosaveActive() && pageMaybeDirty();
268
+ if (!awaitingManualSave) {
269
+ if (document.documentElement.getAttribute('savestatus') === 'conflict') {
270
+ document.documentElement.setAttribute('savestatus', 'saved');
271
+ }
272
+ document.dispatchEvent(new CustomEvent('clay:save-conflict-resolved', {
273
+ detail: { timestamp: Date.now() }
274
+ }));
275
+ }
276
+ resumeAutosave();
277
+ }
278
+
248
279
  function skipped_(msg) {
249
280
  return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
250
281
  }
@@ -275,10 +306,11 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
275
306
  logBaseline(label, `${lastSavedContents.length} chars`);
276
307
  releaseConflictHold();
277
308
  } else if (result.msgType === 'conflict') {
278
- holdForConflict();
309
+ holdForConflict(result.conflictEtag);
279
310
  setSaveState('conflict', result.msg, result.msgType, {
280
311
  changedBy: result.changedBy ?? null,
281
312
  afterTimeout: result.afterTimeout === true,
313
+ etag: result.conflictEtag ?? null,
282
314
  });
283
315
  } else if (result.msgType !== 'skipped') {
284
316
  if (!navigator.onLine) {
@@ -540,6 +572,11 @@ let baselineContents = '';
540
572
  // The baseline veto only guards the load-time settle window; captureBaseline
541
573
  // disarms it. See the comment there.
542
574
  let baselineActive = true;
575
+ // True once the load-time settle has run. Live sync refreshes an untouched tab's
576
+ // merge bases at this moment: modules that build DOM at boot (an editor mounting
577
+ // from an editmode:resource script) are in the page by now.
578
+ let baselineSettledAt = false;
579
+ export function baselineSettled() { return baselineSettledAt; }
543
580
 
544
581
  // ============================================
545
582
  // BASELINE CAPTURE (Settled Signal)
@@ -626,6 +663,8 @@ function initBaselineCapture() {
626
663
  baselineActive = false;
627
664
 
628
665
  document.documentElement.setAttribute('savestatus', 'saved');
666
+ baselineSettledAt = true;
667
+ document.dispatchEvent(new CustomEvent('clay:baseline-settled'));
629
668
  };
630
669
 
631
670
  // Start settle observer - fires when no mutations for SETTLE_MS.
@@ -464,7 +464,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
464
464
  // and a baseline the dirty check cannot reproduce warns on close forever.
465
465
  if (emitForSync) {
466
466
  document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
467
- detail: { documentElement: clone }
467
+ detail: { documentElement: clone, forSave: true }
468
468
  }));
469
469
  }
470
470
 
@@ -44,6 +44,9 @@ const RECENT_GESTURE_MS = 500;
44
44
 
45
45
  let gestureTaskActive = false;
46
46
  let lastTrustedGestureTs = -Infinity;
47
+ // Set by the first trusted gesture and never cleared. Live sync reads it at the
48
+ // load-time settle: a page nobody has touched yet holds no one's edit.
49
+ let anyGesture = false;
47
50
  let userDrivenSinceLastSave = false;
48
51
  let explicitSaveIntent = false;
49
52
  let installed = false;
@@ -55,6 +58,7 @@ function now() {
55
58
  }
56
59
 
57
60
  function markGestureTurn() {
61
+ anyGesture = true;
58
62
  gestureTaskActive = true;
59
63
  lastTrustedGestureTs = now();
60
64
  // Clear the same-turn flag on the next macrotask. The MutationObserver
@@ -100,6 +104,11 @@ export function markUserDriven() {
100
104
  userDrivenSinceLastSave = true;
101
105
  }
102
106
 
107
+ /** True once any trusted gesture has reached the page. */
108
+ export function gestureSeen() {
109
+ return anyGesture;
110
+ }
111
+
103
112
  /**
104
113
  * Read-and-reset the accumulated bit. Called at the ACTUAL save send (not on a
105
114
  * save that never ships), so it survives the autosave debounce and coalescing.
@@ -144,6 +153,7 @@ export function _resetUserGesture() {
144
153
  gestureTaskActive = false;
145
154
  lastTrustedGestureTs = -Infinity;
146
155
  userDrivenSinceLastSave = false;
156
+ anyGesture = false;
147
157
  explicitSaveIntent = false;
148
158
  }
149
159
 
@@ -11,7 +11,7 @@
11
11
  * ▼
12
12
  * ┌─────────────────────────────────────────────────────────┐
13
13
  * │ 2. SEND POST snapshot to the relay address │
14
- * │ (debounced, skip if unchanged) │
14
+ * │ (after the save lands, with its stamp) │
15
15
  * └─────────────────────────────────────────────────────────┘
16
16
  * │
17
17
  * ▼
@@ -55,8 +55,9 @@ import { presence } from './presence.js';
55
55
  // `clay:sync-applied`, which this file is the only dispatcher of.
56
56
  import './section-notice.js';
57
57
  import { hostMeta } from '../core/host-meta.js';
58
- import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
58
+ import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves } from '../core/etag.js';
59
59
  import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged, gateMarkDirty } from '../lib/dirty-gate.js';
60
+ import { gestureSeen } from '../lib/user-gesture.js';
60
61
  import { SyncStream } from './stream.js';
61
62
 
62
63
  // What a live-sync merge never reads or touches on any side: editor chrome,
@@ -144,7 +145,7 @@ function pairedBaseline() {
144
145
  const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
145
146
  return [forComparison, forDirty];
146
147
  }
147
- import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes } from '../core/save.js';
148
+ import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted, baselineSettled } from '../core/save.js';
148
149
 
149
150
  /**
150
151
  * The two live-sync wires, and the rule for choosing between them.
@@ -227,15 +228,12 @@ class LiveSync {
227
228
  // durable per-tab identity used for echo suppression.
228
229
  this.resumeId = null;
229
230
 
230
- this.debounceMs = 150;
231
- this.debounceTimer = null;
232
-
233
231
  // The sync serialization of the clone the CURRENT save was captured from,
234
232
  // held so the commit relay can pair the host's stamp with the content that
235
233
  // stamp actually describes. Set at snapshot-ready, which fires before the
236
234
  // save POST goes out, and consumed by _relayCommit when the response lands.
237
235
  // Never reconstructed from lastHtml: that is the last relay that COMPLETED,
238
- // which lags the save whenever the response beats the 150ms debounce, and
236
+ // which lags the save whenever an earlier relay is still in flight, and
239
237
  // pairing a fresh stamp with older bytes is the one thing spec §10 forbids.
240
238
  this._savedSnapshot = null;
241
239
  this._sendInFlight = false;
@@ -305,6 +303,8 @@ class LiveSync {
305
303
  // equal our state.
306
304
  this._saveEpoch = 0;
307
305
  this._saveSavedHandler = null;
306
+ this._saveConflictHandler = null;
307
+ this._settledHandler = null;
308
308
 
309
309
  // The identityMap that came with lastHtml, so the peer-lane dirty diff
310
310
  // can resolve synthetic identities on its base tree.
@@ -382,6 +382,19 @@ class LiveSync {
382
382
  }
383
383
  }
384
384
 
385
+ /**
386
+ * Seed both merge bases (the peer lane's and the disk lane's) with the page as
387
+ * it stands, so a first frame that arrives after local edits merges instead of
388
+ * holding. A page already dirty has no trustworthy base; it keeps null and holds.
389
+ */
390
+ _seedBases() {
391
+ if (pageMaybeDirty()) return;
392
+ const clone = captureSnapshot({ flushUndo: false });
393
+ this.lastHtml = serializeForSync(clone);
394
+ this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
395
+ this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
396
+ }
397
+
385
398
  /**
386
399
  * Start the LiveSync system
387
400
  * Can be called after stop() to restart with a new file
@@ -419,22 +432,53 @@ class LiveSync {
419
432
  this._pendingExternal = null;
420
433
  this.resumeId = this.generateResumeId();
421
434
 
422
- // Seed both merge bases (the peer lane's and the disk lane's) with the page
423
- // as served, so a first frame that arrives after local edits merges instead
424
- // of holding. A page already dirty at start has no trustworthy base; it
425
- // keeps null and holds.
426
- if (this.lane === 'live' && !pageMaybeDirty()) {
427
- const clone = captureSnapshot({ flushUndo: false });
428
- this.lastHtml = serializeForSync(clone);
429
- this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
430
- this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
431
- }
435
+ // Seed both merge bases now, so a first frame that arrives after local edits
436
+ // merges instead of holding. A page still booting is refreshed at the settle
437
+ // below: a base taken before a module built its DOM makes the first dirty merge
438
+ // see both sides add that DOM, and keep it twice.
439
+ if (this.lane === 'live') this._seedBases();
432
440
 
433
441
  console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
434
442
  // One discovery request stands between here and the stream. It is memoized and
435
443
  // bounded, and it does not gate ordinary saves — /_/save is a separate lane that
436
444
  // never moved. Snapshots produced during the window are queued, not dropped.
437
445
  const gen = ++this._startGen;
446
+ if (this.lane === 'live' && !baselineSettled()) {
447
+ const applyGen = this._applyGen;
448
+ const saveEpoch = this._saveEpoch;
449
+ const startHtml = this.lastHtml;
450
+ const startDiskTicket = this._diskBaseTicket;
451
+ const startDiskSeeded = this._diskBase !== null;
452
+ this._settledHandler = () => {
453
+ this._settledHandler = null;
454
+ if (this.isDestroyed || this._startGen !== gen) return;
455
+ // A person has touched the page: whatever they did (a drag, a button whose
456
+ // handler edits a turn later) may be in it and not yet saved. Keep the start
457
+ // seed, as 1.5.2 did; only an untouched tab gets the settled page as its base.
458
+ if (gestureSeen()) return;
459
+ // The settle clears the gate only when it saw no user edit, so a dirty page
460
+ // here holds work the base must not absorb: keep the start seed.
461
+ if (pageMaybeDirty()) return;
462
+ // A save this tab sent is still unconfirmed: what it carries is not yet
463
+ // common history, and a refused save must merge against the older base.
464
+ if (this._saveTicket !== 0) return;
465
+ // Refresh each lane only while it still holds what start left. A frame, a
466
+ // landed relay, an own save or a disk frame since then gave it a newer base
467
+ // (an own save counts even when its relay failed and lastHtml never moved).
468
+ // Only replace a seed start took, never fill a base start left empty: a page
469
+ // dirty at start held its frames in 1.5.2, and its own scripts may still be
470
+ // rewriting content the merge cannot reach.
471
+ if (startHtml !== null && this._applyGen === applyGen && this._saveEpoch === saveEpoch && this.lastHtml === startHtml) {
472
+ const clone = captureSnapshot({ flushUndo: false });
473
+ this.lastHtml = serializeForSync(clone);
474
+ this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
475
+ }
476
+ if (startDiskSeeded && this._diskBaseTicket === startDiskTicket) {
477
+ this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
478
+ }
479
+ };
480
+ document.addEventListener('clay:baseline-settled', this._settledHandler, { once: true });
481
+ }
438
482
  // Exposed so a caller (and the tests) can await the point where the stream is
439
483
  // actually open, rather than counting microtasks behind the discovery request.
440
484
  this._ready = this._resolveProfile().then(() => {
@@ -467,6 +511,23 @@ class LiveSync {
467
511
  this._relayCommit();
468
512
  };
469
513
  document.addEventListener('clay:save-saved', this._saveSavedHandler);
514
+ // A 412 can arrive after this tab already merged the version that beat it:
515
+ // the winner's relay overtook the refusal. The refusal then names a stamp
516
+ // this tab already holds, and nothing else would ever release the hold.
517
+ // Deferred a microtask so the conflict notice's own listener has shown the
518
+ // bar before this hides it.
519
+ this._saveConflictHandler = (event) => {
520
+ const refusedBy = event.detail && event.detail.etag;
521
+ if (!refusedBy) return;
522
+ queueMicrotask(() => {
523
+ if (this.isDestroyed) return;
524
+ if (refusedBy !== lastSeenEtag()) return;
525
+ if (this.unresolvedConflicts.length) return;
526
+ conflictResolvedBySync(refusedBy);
527
+ this._saveAfterMerge(pageMaybeDirty(), false);
528
+ });
529
+ };
530
+ document.addEventListener('clay:save-conflict', this._saveConflictHandler);
470
531
  }
471
532
  }
472
533
 
@@ -494,7 +555,16 @@ class LiveSync {
494
555
  this._saveSavedHandler = null;
495
556
  }
496
557
 
497
- clearTimeout(this.debounceTimer);
558
+ if (this._saveConflictHandler) {
559
+ document.removeEventListener('clay:save-conflict', this._saveConflictHandler);
560
+ this._saveConflictHandler = null;
561
+ }
562
+
563
+ if (this._settledHandler) {
564
+ document.removeEventListener('clay:baseline-settled', this._settledHandler);
565
+ this._settledHandler = null;
566
+ }
567
+
498
568
  this._queuedSend = null;
499
569
 
500
570
  // Cancel any pending frame and clear the queue. A morph already in
@@ -751,6 +821,14 @@ class LiveSync {
751
821
 
752
822
  const etag = typeof data.etag === 'string' && data.etag ? data.etag : null;
753
823
 
824
+ // On a host that stamps saves, every landed save is relayed with its stamp.
825
+ // A live-lane frame without one is an older client's preview of a save that
826
+ // may yet be refused; the stamped frame follows if it lands.
827
+ if (this.lane === 'live' && !etag && conditionalSaves()) {
828
+ this._log(`Dropping an unstamped live frame on a stamping host (seq=${seq})`);
829
+ return;
830
+ }
831
+
754
832
  // The common case, and it costs nothing: a peer saved bytes this tab has
755
833
  // already applied, so the frame's only news is the stamp. Taking it without
756
834
  // a morph is exactly as safe as taking it after one, because the baseline
@@ -758,6 +836,10 @@ class LiveSync {
758
836
  if (etag && html === this.lastHtml) {
759
837
  this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
760
838
  recordEtag(etag);
839
+ if (isSaveConflicted() && this.unresolvedConflicts.length === 0) {
840
+ conflictResolvedBySync(etag);
841
+ this._saveAfterMerge(pageMaybeDirty(), false);
842
+ }
761
843
  return;
762
844
  }
763
845
 
@@ -785,15 +867,15 @@ class LiveSync {
785
867
  */
786
868
  listenForSnapshots() {
787
869
  this._snapshotHandler = (event) => {
870
+ // Only a save's own capture names what the next landed save stored, and
871
+ // when. A capture made for any other reason (the public captureForSave)
872
+ // must not replace the payload that save will relay, nor move its ticket.
873
+ if (!event.detail || event.detail.forSave !== true) return;
788
874
  // Every save captures through this event, before its request goes out,
789
- // so this is the moment the save's bytes were true of the document. Taken
790
- // ahead of the pause check: a save made during a frame's await is still a
791
- // save, and it is newer than that frame.
875
+ // so this is the moment the save's bytes were true of the document. A
876
+ // save made during a frame's await is still a save, and it is newer than
877
+ // that frame.
792
878
  this._saveTicket = this._ticket();
793
- if (this.isPaused) {
794
- this._log('snapshot-ready received but isPaused, skipping');
795
- return;
796
- }
797
879
 
798
880
  const { documentElement: clone } = event.detail;
799
881
  if (!clone) return;
@@ -810,43 +892,33 @@ class LiveSync {
810
892
  // content its stamp will describe. Held for _relayCommit, and overwritten
811
893
  // by the next capture, so it always names the save currently in flight.
812
894
  this._savedSnapshot = { html, identityMap };
813
-
814
- this.sendUpdate(html, identityMap);
815
895
  };
816
896
 
817
897
  document.addEventListener('clay:snapshot-ready', this._snapshotHandler);
818
898
  }
819
899
 
820
900
  /**
821
- * Send full HTML to the server (debounced, single-flight).
901
+ * Send a landed save's HTML to the relay (single-flight).
822
902
  *
823
903
  * One POST on the wire at a time, with at most one newer payload queued; a
824
904
  * fresher snapshot replaces the queued one, because peers only ever need the
825
905
  * latest state. Two concurrent fetches could reach the server in either order,
826
906
  * and last-write-wins then stored the OLDER snapshot for every peer.
827
907
  */
828
- sendUpdate(html, identityMap) {
829
- clearTimeout(this.debounceTimer);
830
-
831
- this.debounceTimer = setTimeout(() => {
832
- this._enqueueSend(html, identityMap);
833
- }, this.debounceMs);
834
- }
835
-
836
- _enqueueSend(html, identityMap) {
908
+ _enqueueSend(html, identityMap, etag = null) {
837
909
  // Same one-deep queue the in-flight case uses, for the same reason: peers only
838
910
  // ever need the newest state, so a fresher snapshot replaces the waiting one.
839
911
  // Sending before the profile is known would have to guess an address.
840
912
  if (!this._profile) {
841
- this._queuedSend = { html, identityMap };
913
+ this._queuedSend = { html, identityMap, etag };
842
914
  this._resolveProfile().then(() => this._flushQueuedSend());
843
915
  return;
844
916
  }
845
917
  if (this._sendInFlight) {
846
- this._queuedSend = { html, identityMap };
918
+ this._queuedSend = { html, identityMap, etag };
847
919
  return;
848
920
  }
849
- this._postUpdate(html, identityMap);
921
+ this._postUpdate(html, identityMap, etag);
850
922
  }
851
923
 
852
924
  _flushQueuedSend() {
@@ -854,7 +926,7 @@ class LiveSync {
854
926
  const queued = this._queuedSend;
855
927
  if (!queued) return;
856
928
  this._queuedSend = null;
857
- this._postUpdate(queued.html, queued.identityMap);
929
+ this._postUpdate(queued.html, queued.identityMap, queued.etag);
858
930
  }
859
931
 
860
932
  /**
@@ -862,23 +934,14 @@ class LiveSync {
862
934
  *
863
935
  * Runs on clay:save-saved, by which point the save response has already been
864
936
  * recorded, so `lastSeenEtag()` is the stamp for the bytes that just landed.
865
- *
866
- * The content is `lastHtml`, the snapshot this tab most recently relayed, which
867
- * is the state the save was taken from: the save pipeline dispatches
868
- * clay:snapshot-ready on its way to capturing what to send, so that relay has
869
- * already gone out. Re-sending those same bytes is not redundant, because the
870
- * stamp is the new information and a stamp may never travel without the content
871
- * it describes. A receiver whose baseline already matches takes the stamp and
872
- * skips the morph.
937
+ * The content is `_savedSnapshot`, captured by that save's own snapshot-ready
938
+ * event, never `lastHtml`: a capture is not relayed until its save lands, so
939
+ * peers only ever see versions the host accepted, each descending from the one
940
+ * before. A host that returns no stamp still gets the relay, without one.
873
941
  */
874
942
  _relayCommit() {
875
- // Holding a stamp is the whole condition. It is set only from a save response
876
- // that carried one, and a host that does not do conditional saves returns
877
- // none, so this is the same test as "the host stamps what it stores" without
878
- // a second flag that would have to be reached through discovery to exercise.
943
+ if (this.isDestroyed) return;
879
944
  const etag = lastSeenEtag();
880
- if (!etag) return;
881
- if (this.isDestroyed || this.isPaused) return;
882
945
 
883
946
  // The content this save stored, captured with it. Consumed rather than left
884
947
  // behind, so a save-saved with no capture of its own can never reuse an
@@ -886,51 +949,18 @@ class LiveSync {
886
949
  const pending = this._savedSnapshot;
887
950
  this._savedSnapshot = null;
888
951
 
889
- // No captured snapshot means nothing here knows which bytes this stamp
890
- // describes, and §10 is explicit that a stamp must never travel on its own.
891
- // Staying silent costs the other editors one refusal they recover from;
892
- // guessing costs somebody their work.
952
+ // No captured snapshot means nothing here knows which bytes this save
953
+ // stored, and §10 is explicit that a stamp must never travel on its own.
893
954
  if (!pending || typeof pending.html !== 'string') return;
894
955
 
895
- this._postCommit(pending.html, etag, pending.identityMap);
956
+ this._enqueueSend(pending.html, pending.identityMap, etag || null);
896
957
  }
897
958
 
898
- /**
899
- * Post a snapshot whose point is the stamp attached to it.
900
- *
901
- * Deliberately not `_postUpdate`: that one returns early when the html matches
902
- * `lastHtml`, and the whole point here is that the stamp is the new information
903
- * even when the bytes are not. It also does not touch `lastHtml` or the
904
- * single-flight queue, because it must not displace a real snapshot waiting to
905
- * go out. It carries the identityMap captured with these bytes, so a receiver
906
- * that morphs on this frame pairs elements the same way it would on the
907
- * ordinary relay of the same content.
908
- */
909
- _postCommit(html, etag, identityMap) {
910
- const profile = this._profile;
911
- if (!profile) return;
912
- fetch(new URL(profile.relayPath, window.location.origin).href, {
913
- method: 'POST',
914
- headers: {
915
- 'Content-Type': 'application/json',
916
- [profile.documentHeader]: window.location.href,
917
- },
918
- body: JSON.stringify({
919
- [profile.snapshotKey]: html,
920
- sender: this.clientId,
921
- identityMap,
922
- etag
923
- })
924
- }).catch(err => {
925
- // Losing this costs the other editors one spurious refusal on their next
926
- // save, which they recover from. It is not worth surfacing as an error.
927
- this._log('Commit relay failed: ' + (err && err.message));
928
- });
929
- }
930
-
931
- _postUpdate(html, identityMap) {
932
- // Skip if unchanged
933
- if (html === this.lastHtml) {
959
+ _postUpdate(html, identityMap, etag = null, attempt = 0) {
960
+ // Skip only an unstamped repeat. A landed save is news even when its bytes
961
+ // equal the last relay: "Keep mine" can restore exactly those bytes after
962
+ // the peers moved on, and they must hear it.
963
+ if (!etag && html === this.lastHtml) {
934
964
  this._log('Skipping send - HTML unchanged');
935
965
  return;
936
966
  }
@@ -939,6 +969,7 @@ class LiveSync {
939
969
 
940
970
  this._sendInFlight = true;
941
971
  const gen = this._applyGen;
972
+ let retry = false;
942
973
 
943
974
  // Absolute against the real origin, so a <base href> in the page cannot
944
975
  // redirect the whole document to an origin the author picked.
@@ -952,7 +983,8 @@ class LiveSync {
952
983
  body: JSON.stringify({
953
984
  [profile.snapshotKey]: html,
954
985
  sender: this.clientId,
955
- identityMap: identityMap
986
+ identityMap: identityMap,
987
+ ...(etag ? { etag } : {})
956
988
  })
957
989
  }).then(response => {
958
990
  if (response.ok) {
@@ -968,15 +1000,33 @@ class LiveSync {
968
1000
  }
969
1001
  } else {
970
1002
  console.warn('[LiveSync] Save returned status:', response.status);
1003
+ retry = response.status >= 500 || response.status === 408 || response.status === 429;
971
1004
  }
972
1005
  }).catch(err => {
973
1006
  console.error('[LiveSync] Save failed:', err);
974
1007
  if (this.onError) this.onError(err);
1008
+ retry = true;
975
1009
  }).finally(() => {
976
1010
  this._sendInFlight = false;
977
1011
  const queued = this._queuedSend;
978
1012
  this._queuedSend = null;
979
- if (queued) this._postUpdate(queued.html, queued.identityMap);
1013
+ if (queued) {
1014
+ this._postUpdate(queued.html, queued.identityMap, queued.etag);
1015
+ return;
1016
+ }
1017
+ // The relay of a landed save is the only one peers get, so a transient
1018
+ // failure is retried a few times. A newer save queued meanwhile replaces
1019
+ // it: peers only need the latest.
1020
+ if (!retry || !etag || attempt >= 3 || this.isDestroyed) return;
1021
+ this._sendInFlight = true;
1022
+ setTimeout(() => {
1023
+ this._sendInFlight = false;
1024
+ if (this.isDestroyed) return;
1025
+ const newer = this._queuedSend;
1026
+ this._queuedSend = null;
1027
+ if (newer) this._postUpdate(newer.html, newer.identityMap, newer.etag);
1028
+ else this._postUpdate(html, identityMap, etag, attempt + 1);
1029
+ }, 500 * 2 ** attempt);
980
1030
  });
981
1031
  }
982
1032
 
@@ -1445,6 +1495,7 @@ class LiveSync {
1445
1495
 
1446
1496
  let diverged = false;
1447
1497
  let typedDuringWait = false;
1498
+ let stamped = false;
1448
1499
  try {
1449
1500
  // Hold the whole frame only when this tab has unsaved edits and no
1450
1501
  // baseline to merge them against (the first frame of a fresh
@@ -1522,7 +1573,10 @@ class LiveSync {
1522
1573
  // above, and deliberately the same moment: the two claims a page makes
1523
1574
  // when it adopts a stamp are "the host stores this version" and "I hold
1524
1575
  // it", and only the second one is this tab's to make.
1525
- if (typeof etag === 'string' && etag) recordEtag(etag);
1576
+ if (typeof etag === 'string' && etag) {
1577
+ recordEtag(etag);
1578
+ stamped = true;
1579
+ }
1526
1580
 
1527
1581
  // Cross-lane baseline: the DOM now holds this frame's content, but the
1528
1582
  // DISK baseline (lastSavedContents) still describes pre-frame state. A
@@ -1593,9 +1647,12 @@ class LiveSync {
1593
1647
  // edits + their frame) that exists only in this DOM. Push it out explicitly
1594
1648
  // — the morph ran under Mutation.pause, so no autosave was triggered, and a
1595
1649
  // pending autosave debounce may already have fired mid-flight. Runs after
1596
- // isPaused is lifted so the save's snapshot-ready relay reaches peers.
1650
+ // isPaused is lifted so the convergence save's relay reaches peers.
1597
1651
  // Only where autosave is on: a manual-save page must never auto-write, so
1598
1652
  // there the merge stays local, still dirty, until the person saves.
1653
+ // Not over a lost conflict: the hold and its bar stay up as the signal that
1654
+ // this tab lost text, until the person saves.
1655
+ if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
1599
1656
  this._saveAfterMerge(diverged, typedDuringWait);
1600
1657
  }
1601
1658
 
@@ -1626,6 +1683,7 @@ class LiveSync {
1626
1683
 
1627
1684
  let diverged = false;
1628
1685
  let typedDuringWait = false;
1686
+ let stamped = false;
1629
1687
  try {
1630
1688
  // Hold the whole frame only when this tab has unsaved edits and no
1631
1689
  // baseline to merge them against. Lane-guarded like the peer path: a
@@ -1682,7 +1740,10 @@ class LiveSync {
1682
1740
  // A frame with no stamp (an older host, or the content-less fetch fallback,
1683
1741
  // which serves bytes nobody stamped) leaves this alone, and the listener in
1684
1742
  // etag.js falls back to asking the host.
1685
- if (typeof etag === 'string' && etag) recordEtag(etag);
1743
+ if (typeof etag === 'string' && etag) {
1744
+ recordEtag(etag);
1745
+ stamped = true;
1746
+ }
1686
1747
 
1687
1748
  if (
1688
1749
  this.lane === 'live' &&
@@ -1733,6 +1794,9 @@ class LiveSync {
1733
1794
  // (our edits + their bytes). The baseline was left pre-external, so the
1734
1795
  // save sees both as changes and writes the merge back. Only where
1735
1796
  // autosave is on, as in the peer lane.
1797
+ // Not over a lost conflict: the hold and its bar stay up as the signal that
1798
+ // this tab lost text, until the person saves.
1799
+ if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
1736
1800
  this._saveAfterMerge(diverged, typedDuringWait);
1737
1801
  }
1738
1802