@panphora/clayjs 1.5.1 → 1.5.2

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.2 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;
@@ -1555,7 +1555,7 @@
1555
1555
  const clone = captureSnapshot({ flushUndo });
1556
1556
  if (emitForSync) {
1557
1557
  document.dispatchEvent(new CustomEvent("clay:snapshot-ready", {
1558
- detail: { documentElement: clone }
1558
+ detail: { documentElement: clone, forSave: true }
1559
1559
  }));
1560
1560
  }
1561
1561
  runAuthoredHandlers(clone, "onbeforesave");
@@ -1898,7 +1898,7 @@
1898
1898
  async function seedEtag({ fresh = false, clearIfMissing = fresh } = {}) {
1899
1899
  const at3 = generation;
1900
1900
  const meta = await hostMeta({ fresh });
1901
- conditional = meta.extensions.includes("conditional");
1901
+ if (typeof meta.spec === "number") conditional = meta.extensions.includes("conditional");
1902
1902
  if (generation !== at3) return lastSeen;
1903
1903
  const seed = meta.document?.etag;
1904
1904
  if (typeof seed === "string" && seed !== "") {
@@ -2005,6 +2005,10 @@
2005
2005
  // A refusal that may be answering this tab's own timed-out write. Only the
2006
2006
  // notice uses it, and only to word itself; nothing decides anything on it.
2007
2007
  afterTimeout: conflicted && unknownAttempt !== null,
2008
+ // The stamp the host refused this save against: the version that beat it.
2009
+ // Live sync compares it with the frames it merges to know when the refusal
2010
+ // is answered.
2011
+ conflictEtag: conflicted ? err.etag ?? null : null,
2008
2012
  etag: null
2009
2013
  };
2010
2014
  }
@@ -2377,6 +2381,20 @@
2377
2381
  }
2378
2382
  });
2379
2383
 
2384
+ // src/lib/autosave-state.js
2385
+ function setAutosaveActive(value) {
2386
+ active = !!value;
2387
+ }
2388
+ function autosaveActive() {
2389
+ return active;
2390
+ }
2391
+ var active;
2392
+ var init_autosave_state = __esm({
2393
+ "src/lib/autosave-state.js"() {
2394
+ active = false;
2395
+ }
2396
+ });
2397
+
2380
2398
  // src/lib/autosave-debug.js
2381
2399
  function isDebugEnabled() {
2382
2400
  try {
@@ -2479,6 +2497,7 @@
2479
2497
  var save_exports = {};
2480
2498
  __export(save_exports, {
2481
2499
  addDocumentTransform: () => addDocumentTransform,
2500
+ conflictResolvedBySync: () => conflictResolvedBySync,
2482
2501
  default: () => save_default,
2483
2502
  getLastSavedBytes: () => getLastSavedBytes,
2484
2503
  getLastSavedContents: () => getLastSavedContents,
@@ -2533,7 +2552,8 @@
2533
2552
  autosaveMissed = false;
2534
2553
  savePageThrottled();
2535
2554
  }
2536
- function holdForConflict() {
2555
+ function holdForConflict(etag) {
2556
+ conflictEtag = etag ?? null;
2537
2557
  if (conflictHold) return;
2538
2558
  conflictHold = true;
2539
2559
  suspendAutosave();
@@ -2541,11 +2561,28 @@
2541
2561
  function releaseConflictHold() {
2542
2562
  if (!conflictHold) return;
2543
2563
  conflictHold = false;
2564
+ conflictEtag = null;
2544
2565
  resumeAutosave();
2545
2566
  }
2546
2567
  function isSaveConflicted() {
2547
2568
  return conflictHold;
2548
2569
  }
2570
+ function conflictResolvedBySync(etag) {
2571
+ if (!conflictHold) return;
2572
+ if (conflictEtag && etag !== conflictEtag) return;
2573
+ conflictHold = false;
2574
+ conflictEtag = null;
2575
+ const awaitingManualSave = !autosaveActive() && pageMaybeDirty();
2576
+ if (!awaitingManualSave) {
2577
+ if (document.documentElement.getAttribute("savestatus") === "conflict") {
2578
+ document.documentElement.setAttribute("savestatus", "saved");
2579
+ }
2580
+ document.dispatchEvent(new CustomEvent("clay:save-conflict-resolved", {
2581
+ detail: { timestamp: Date.now() }
2582
+ }));
2583
+ }
2584
+ resumeAutosave();
2585
+ }
2549
2586
  function skipped_(msg) {
2550
2587
  return { ok: false, msg, msgType: "skipped", code: null, etag: null };
2551
2588
  }
@@ -2560,10 +2597,11 @@
2560
2597
  logBaseline(label, `${lastSavedContents.length} chars`);
2561
2598
  releaseConflictHold();
2562
2599
  } else if (result2.msgType === "conflict") {
2563
- holdForConflict();
2600
+ holdForConflict(result2.conflictEtag);
2564
2601
  setSaveState("conflict", result2.msg, result2.msgType, {
2565
2602
  changedBy: result2.changedBy ?? null,
2566
- afterTimeout: result2.afterTimeout === true
2603
+ afterTimeout: result2.afterTimeout === true,
2604
+ etag: result2.conflictEtag ?? null
2567
2605
  });
2568
2606
  } else if (result2.msgType !== "skipped") {
2569
2607
  if (!navigator.onLine) {
@@ -2818,7 +2856,7 @@
2818
2856
  initSaveKeyboardShortcut();
2819
2857
  initHyperclaySaveButton();
2820
2858
  }
2821
- var savingTimeout, unsavedChanges, lastSavedContents, lastSavedDirty, pendingSave, autosaveSuspended, autosaveMissed, conflictHold, lastSavedBytes, throttledSave, baselineContents, baselineActive, SETTLE_MS, MAX_SETTLE_MS, save_default;
2859
+ var savingTimeout, unsavedChanges, lastSavedContents, lastSavedDirty, pendingSave, autosaveSuspended, autosaveMissed, conflictHold, conflictEtag, lastSavedBytes, throttledSave, baselineContents, baselineActive, SETTLE_MS, MAX_SETTLE_MS, save_default;
2822
2860
  var init_save = __esm({
2823
2861
  "src/core/save.js"() {
2824
2862
  init_throttle();
@@ -2828,6 +2866,7 @@
2828
2866
  init_snapshot();
2829
2867
  init_etag();
2830
2868
  init_dirty_gate();
2869
+ init_autosave_state();
2831
2870
  init_root_attrs();
2832
2871
  init_autosave_debug();
2833
2872
  init_user_gesture();
@@ -2856,6 +2895,7 @@
2856
2895
  autosaveSuspended = 0;
2857
2896
  autosaveMissed = false;
2858
2897
  conflictHold = false;
2898
+ conflictEtag = null;
2859
2899
  lastSavedBytes = null;
2860
2900
  throttledSave = throttle_default(savePage, 1200);
2861
2901
  baselineContents = "";
@@ -3012,6 +3052,7 @@
3012
3052
  if (!isEditMode) return;
3013
3053
  document.addEventListener("clay:save-conflict", show);
3014
3054
  document.addEventListener("clay:save-saved", hide);
3055
+ document.addEventListener("clay:save-conflict-resolved", hide);
3015
3056
  document.addEventListener("keydown", onKeydown);
3016
3057
  }
3017
3058
  var BG2, INK2, EDGE2, WARN, FONT2, SOURCES, ARM_MS, root2, line, keep, drop, armTimer, armed, busy, still;
@@ -3369,20 +3410,6 @@
3369
3410
  }
3370
3411
  });
3371
3412
 
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
3413
  // src/core/autosave.js
3387
3414
  var autosave_exports = {};
3388
3415
  __export(autosave_exports, {
@@ -12222,8 +12249,6 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12222
12249
  this._conflictTickets = /* @__PURE__ */ new WeakMap();
12223
12250
  this.clientId = this.generateClientId();
12224
12251
  this.resumeId = null;
12225
- this.debounceMs = 150;
12226
- this.debounceTimer = null;
12227
12252
  this._savedSnapshot = null;
12228
12253
  this._sendInFlight = false;
12229
12254
  this._queuedSend = null;
@@ -12248,6 +12273,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12248
12273
  this._lastExternalSeq = 0;
12249
12274
  this._saveEpoch = 0;
12250
12275
  this._saveSavedHandler = null;
12276
+ this._saveConflictHandler = null;
12251
12277
  this._lastIdentityMap = null;
12252
12278
  this._applyGen = 0;
12253
12279
  this._holdRetryPeer = null;
@@ -12348,6 +12374,18 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12348
12374
  this._relayCommit();
12349
12375
  };
12350
12376
  document.addEventListener("clay:save-saved", this._saveSavedHandler);
12377
+ this._saveConflictHandler = (event) => {
12378
+ const refusedBy = event.detail && event.detail.etag;
12379
+ if (!refusedBy) return;
12380
+ queueMicrotask(() => {
12381
+ if (this.isDestroyed) return;
12382
+ if (refusedBy !== lastSeenEtag()) return;
12383
+ if (this.unresolvedConflicts.length) return;
12384
+ conflictResolvedBySync(refusedBy);
12385
+ this._saveAfterMerge(pageMaybeDirty(), false);
12386
+ });
12387
+ };
12388
+ document.addEventListener("clay:save-conflict", this._saveConflictHandler);
12351
12389
  }
12352
12390
  }
12353
12391
  /**
@@ -12368,7 +12406,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12368
12406
  document.removeEventListener("clay:save-saved", this._saveSavedHandler);
12369
12407
  this._saveSavedHandler = null;
12370
12408
  }
12371
- clearTimeout(this.debounceTimer);
12409
+ if (this._saveConflictHandler) {
12410
+ document.removeEventListener("clay:save-conflict", this._saveConflictHandler);
12411
+ this._saveConflictHandler = null;
12412
+ }
12372
12413
  this._queuedSend = null;
12373
12414
  if (this._rafHandle != null) {
12374
12415
  this._cancelFrame(this._rafHandle);
@@ -12548,9 +12589,17 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12548
12589
  return;
12549
12590
  }
12550
12591
  const etag = typeof data.etag === "string" && data.etag ? data.etag : null;
12592
+ if (this.lane === "live" && !etag && conditionalSaves()) {
12593
+ this._log(`Dropping an unstamped live frame on a stamping host (seq=${seq})`);
12594
+ return;
12595
+ }
12551
12596
  if (etag && html === this.lastHtml) {
12552
12597
  this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
12553
12598
  recordEtag(etag);
12599
+ if (isSaveConflicted() && this.unresolvedConflicts.length === 0) {
12600
+ conflictResolvedBySync(etag);
12601
+ this._saveAfterMerge(pageMaybeDirty(), false);
12602
+ }
12554
12603
  return;
12555
12604
  }
12556
12605
  this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
@@ -12573,115 +12622,71 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12573
12622
  */
12574
12623
  listenForSnapshots() {
12575
12624
  this._snapshotHandler = (event) => {
12625
+ if (!event.detail || event.detail.forSave !== true) return;
12576
12626
  this._saveTicket = this._ticket();
12577
- if (this.isPaused) {
12578
- this._log("snapshot-ready received but isPaused, skipping");
12579
- return;
12580
- }
12581
12627
  const { documentElement: clone } = event.detail;
12582
12628
  if (!clone) return;
12583
12629
  this._log("snapshot-ready received, preparing to send");
12584
12630
  const html = serializeForSync(clone);
12585
12631
  const identityMap = this.identity.exportMap(clone, originalSnapshotNode);
12586
12632
  this._savedSnapshot = { html, identityMap };
12587
- this.sendUpdate(html, identityMap);
12588
12633
  };
12589
12634
  document.addEventListener("clay:snapshot-ready", this._snapshotHandler);
12590
12635
  }
12591
12636
  /**
12592
- * Send full HTML to the server (debounced, single-flight).
12637
+ * Send a landed save's HTML to the relay (single-flight).
12593
12638
  *
12594
12639
  * One POST on the wire at a time, with at most one newer payload queued; a
12595
12640
  * fresher snapshot replaces the queued one, because peers only ever need the
12596
12641
  * latest state. Two concurrent fetches could reach the server in either order,
12597
12642
  * and last-write-wins then stored the OLDER snapshot for every peer.
12598
12643
  */
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) {
12644
+ _enqueueSend(html, identityMap, etag = null) {
12606
12645
  if (!this._profile) {
12607
- this._queuedSend = { html, identityMap };
12646
+ this._queuedSend = { html, identityMap, etag };
12608
12647
  this._resolveProfile().then(() => this._flushQueuedSend());
12609
12648
  return;
12610
12649
  }
12611
12650
  if (this._sendInFlight) {
12612
- this._queuedSend = { html, identityMap };
12651
+ this._queuedSend = { html, identityMap, etag };
12613
12652
  return;
12614
12653
  }
12615
- this._postUpdate(html, identityMap);
12654
+ this._postUpdate(html, identityMap, etag);
12616
12655
  }
12617
12656
  _flushQueuedSend() {
12618
12657
  if (this.isDestroyed || this._sendInFlight) return;
12619
12658
  const queued = this._queuedSend;
12620
12659
  if (!queued) return;
12621
12660
  this._queuedSend = null;
12622
- this._postUpdate(queued.html, queued.identityMap);
12661
+ this._postUpdate(queued.html, queued.identityMap, queued.etag);
12623
12662
  }
12624
12663
  /**
12625
12664
  * Tell the other editors what this tab's save stored, and under which stamp.
12626
12665
  *
12627
12666
  * Runs on clay:save-saved, by which point the save response has already been
12628
12667
  * 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.
12668
+ * The content is `_savedSnapshot`, captured by that save's own snapshot-ready
12669
+ * event, never `lastHtml`: a capture is not relayed until its save lands, so
12670
+ * peers only ever see versions the host accepted, each descending from the one
12671
+ * before. A host that returns no stamp still gets the relay, without one.
12637
12672
  */
12638
12673
  _relayCommit() {
12674
+ if (this.isDestroyed) return;
12639
12675
  const etag = lastSeenEtag();
12640
- if (!etag) return;
12641
- if (this.isDestroyed || this.isPaused) return;
12642
12676
  const pending2 = this._savedSnapshot;
12643
12677
  this._savedSnapshot = null;
12644
12678
  if (!pending2 || typeof pending2.html !== "string") return;
12645
- this._postCommit(pending2.html, etag, pending2.identityMap);
12646
- }
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
- });
12679
+ this._enqueueSend(pending2.html, pending2.identityMap, etag || null);
12676
12680
  }
12677
- _postUpdate(html, identityMap) {
12678
- if (html === this.lastHtml) {
12681
+ _postUpdate(html, identityMap, etag = null, attempt = 0) {
12682
+ if (!etag && html === this.lastHtml) {
12679
12683
  this._log("Skipping send - HTML unchanged");
12680
12684
  return;
12681
12685
  }
12682
12686
  this._log(`Sending update (HTML length: ${html.length}, lastHtml length: ${this.lastHtml?.length || 0})`);
12683
12687
  this._sendInFlight = true;
12684
12688
  const gen = this._applyGen;
12689
+ let retry = false;
12685
12690
  const profile = this._profile || WIRE_PROFILES.legacy;
12686
12691
  fetch(new URL(profile.relayPath, window.location.origin).href, {
12687
12692
  method: "POST",
@@ -12692,7 +12697,8 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12692
12697
  body: JSON.stringify({
12693
12698
  [profile.snapshotKey]: html,
12694
12699
  sender: this.clientId,
12695
- identityMap
12700
+ identityMap,
12701
+ ...etag ? { etag } : {}
12696
12702
  })
12697
12703
  }).then((response) => {
12698
12704
  if (response.ok) {
@@ -12704,15 +12710,30 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12704
12710
  }
12705
12711
  } else {
12706
12712
  console.warn("[LiveSync] Save returned status:", response.status);
12713
+ retry = response.status >= 500 || response.status === 408 || response.status === 429;
12707
12714
  }
12708
12715
  }).catch((err) => {
12709
12716
  console.error("[LiveSync] Save failed:", err);
12710
12717
  if (this.onError) this.onError(err);
12718
+ retry = true;
12711
12719
  }).finally(() => {
12712
12720
  this._sendInFlight = false;
12713
12721
  const queued = this._queuedSend;
12714
12722
  this._queuedSend = null;
12715
- if (queued) this._postUpdate(queued.html, queued.identityMap);
12723
+ if (queued) {
12724
+ this._postUpdate(queued.html, queued.identityMap, queued.etag);
12725
+ return;
12726
+ }
12727
+ if (!retry || !etag || attempt >= 3 || this.isDestroyed) return;
12728
+ this._sendInFlight = true;
12729
+ setTimeout(() => {
12730
+ this._sendInFlight = false;
12731
+ if (this.isDestroyed) return;
12732
+ const newer = this._queuedSend;
12733
+ this._queuedSend = null;
12734
+ if (newer) this._postUpdate(newer.html, newer.identityMap, newer.etag);
12735
+ else this._postUpdate(html, identityMap, etag, attempt + 1);
12736
+ }, 500 * 2 ** attempt);
12716
12737
  });
12717
12738
  }
12718
12739
  /**
@@ -13070,6 +13091,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13070
13091
  pauseGate();
13071
13092
  let diverged = false;
13072
13093
  let typedDuringWait = false;
13094
+ let stamped = false;
13073
13095
  try {
13074
13096
  if (this.lane === "live" && pageMaybeDirty() && this.lastHtml === null) {
13075
13097
  console.log("[LiveSync] Holding incoming update: unsaved local edits and no baseline to merge against");
@@ -13103,7 +13125,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13103
13125
  this.lastHtml = html;
13104
13126
  this._lastIdentityMap = identityMap && typeof identityMap === "object" && !Array.isArray(identityMap) ? identityMap : null;
13105
13127
  this._applyGen++;
13106
- if (typeof etag === "string" && etag) recordEtag(etag);
13128
+ if (typeof etag === "string" && etag) {
13129
+ recordEtag(etag);
13130
+ stamped = true;
13131
+ }
13107
13132
  if (this.lane === "live" && !diverged && !pageMaybeDirty() && this.unresolvedConflicts.length === 0) {
13108
13133
  const { forSave, forComparison, forDirty } = captureForSaveAndComparison({
13109
13134
  emitForSync: false,
@@ -13122,6 +13147,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13122
13147
  await new Promise((resolve) => setTimeout(resolve, 0));
13123
13148
  this.isPaused = false;
13124
13149
  }
13150
+ if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
13125
13151
  this._saveAfterMerge(diverged, typedDuringWait);
13126
13152
  }
13127
13153
  /**
@@ -13148,6 +13174,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13148
13174
  pauseGate();
13149
13175
  let diverged = false;
13150
13176
  let typedDuringWait = false;
13177
+ let stamped = false;
13151
13178
  try {
13152
13179
  if (this.lane === "live" && pageMaybeDirty() && this._diskBase === null) {
13153
13180
  console.log("[LiveSync] Holding external change: unsaved local edits and no baseline to merge against");
@@ -13173,7 +13200,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13173
13200
  typedDuringWait = typed;
13174
13201
  this._setDiskBase(html, ticket);
13175
13202
  window.scrollTo(scrollX, scrollY);
13176
- if (typeof etag === "string" && etag) recordEtag(etag);
13203
+ if (typeof etag === "string" && etag) {
13204
+ recordEtag(etag);
13205
+ stamped = true;
13206
+ }
13177
13207
  if (this.lane === "live" && !diverged && !pageMaybeDirty() && this.unresolvedConflicts.length === 0) {
13178
13208
  setLastSavedBaselines(...pairedBaseline());
13179
13209
  setUnsavedChanges(false);
@@ -13201,6 +13231,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
13201
13231
  await new Promise((resolve) => setTimeout(resolve, 0));
13202
13232
  this.isPaused = false;
13203
13233
  }
13234
+ if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
13204
13235
  this._saveAfterMerge(diverged, typedDuringWait);
13205
13236
  }
13206
13237
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
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) {
@@ -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
 
@@ -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,7 +55,7 @@ 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
60
  import { SyncStream } from './stream.js';
61
61
 
@@ -144,7 +144,7 @@ function pairedBaseline() {
144
144
  const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
145
145
  return [forComparison, forDirty];
146
146
  }
147
- import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes } from '../core/save.js';
147
+ import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted } from '../core/save.js';
148
148
 
149
149
  /**
150
150
  * The two live-sync wires, and the rule for choosing between them.
@@ -227,15 +227,12 @@ class LiveSync {
227
227
  // durable per-tab identity used for echo suppression.
228
228
  this.resumeId = null;
229
229
 
230
- this.debounceMs = 150;
231
- this.debounceTimer = null;
232
-
233
230
  // The sync serialization of the clone the CURRENT save was captured from,
234
231
  // held so the commit relay can pair the host's stamp with the content that
235
232
  // stamp actually describes. Set at snapshot-ready, which fires before the
236
233
  // save POST goes out, and consumed by _relayCommit when the response lands.
237
234
  // Never reconstructed from lastHtml: that is the last relay that COMPLETED,
238
- // which lags the save whenever the response beats the 150ms debounce, and
235
+ // which lags the save whenever an earlier relay is still in flight, and
239
236
  // pairing a fresh stamp with older bytes is the one thing spec §10 forbids.
240
237
  this._savedSnapshot = null;
241
238
  this._sendInFlight = false;
@@ -305,6 +302,7 @@ class LiveSync {
305
302
  // equal our state.
306
303
  this._saveEpoch = 0;
307
304
  this._saveSavedHandler = null;
305
+ this._saveConflictHandler = null;
308
306
 
309
307
  // The identityMap that came with lastHtml, so the peer-lane dirty diff
310
308
  // can resolve synthetic identities on its base tree.
@@ -467,6 +465,23 @@ class LiveSync {
467
465
  this._relayCommit();
468
466
  };
469
467
  document.addEventListener('clay:save-saved', this._saveSavedHandler);
468
+ // A 412 can arrive after this tab already merged the version that beat it:
469
+ // the winner's relay overtook the refusal. The refusal then names a stamp
470
+ // this tab already holds, and nothing else would ever release the hold.
471
+ // Deferred a microtask so the conflict notice's own listener has shown the
472
+ // bar before this hides it.
473
+ this._saveConflictHandler = (event) => {
474
+ const refusedBy = event.detail && event.detail.etag;
475
+ if (!refusedBy) return;
476
+ queueMicrotask(() => {
477
+ if (this.isDestroyed) return;
478
+ if (refusedBy !== lastSeenEtag()) return;
479
+ if (this.unresolvedConflicts.length) return;
480
+ conflictResolvedBySync(refusedBy);
481
+ this._saveAfterMerge(pageMaybeDirty(), false);
482
+ });
483
+ };
484
+ document.addEventListener('clay:save-conflict', this._saveConflictHandler);
470
485
  }
471
486
  }
472
487
 
@@ -494,7 +509,11 @@ class LiveSync {
494
509
  this._saveSavedHandler = null;
495
510
  }
496
511
 
497
- clearTimeout(this.debounceTimer);
512
+ if (this._saveConflictHandler) {
513
+ document.removeEventListener('clay:save-conflict', this._saveConflictHandler);
514
+ this._saveConflictHandler = null;
515
+ }
516
+
498
517
  this._queuedSend = null;
499
518
 
500
519
  // Cancel any pending frame and clear the queue. A morph already in
@@ -751,6 +770,14 @@ class LiveSync {
751
770
 
752
771
  const etag = typeof data.etag === 'string' && data.etag ? data.etag : null;
753
772
 
773
+ // On a host that stamps saves, every landed save is relayed with its stamp.
774
+ // A live-lane frame without one is an older client's preview of a save that
775
+ // may yet be refused; the stamped frame follows if it lands.
776
+ if (this.lane === 'live' && !etag && conditionalSaves()) {
777
+ this._log(`Dropping an unstamped live frame on a stamping host (seq=${seq})`);
778
+ return;
779
+ }
780
+
754
781
  // The common case, and it costs nothing: a peer saved bytes this tab has
755
782
  // already applied, so the frame's only news is the stamp. Taking it without
756
783
  // a morph is exactly as safe as taking it after one, because the baseline
@@ -758,6 +785,10 @@ class LiveSync {
758
785
  if (etag && html === this.lastHtml) {
759
786
  this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
760
787
  recordEtag(etag);
788
+ if (isSaveConflicted() && this.unresolvedConflicts.length === 0) {
789
+ conflictResolvedBySync(etag);
790
+ this._saveAfterMerge(pageMaybeDirty(), false);
791
+ }
761
792
  return;
762
793
  }
763
794
 
@@ -785,15 +816,15 @@ class LiveSync {
785
816
  */
786
817
  listenForSnapshots() {
787
818
  this._snapshotHandler = (event) => {
819
+ // Only a save's own capture names what the next landed save stored, and
820
+ // when. A capture made for any other reason (the public captureForSave)
821
+ // must not replace the payload that save will relay, nor move its ticket.
822
+ if (!event.detail || event.detail.forSave !== true) return;
788
823
  // 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.
824
+ // so this is the moment the save's bytes were true of the document. A
825
+ // save made during a frame's await is still a save, and it is newer than
826
+ // that frame.
792
827
  this._saveTicket = this._ticket();
793
- if (this.isPaused) {
794
- this._log('snapshot-ready received but isPaused, skipping');
795
- return;
796
- }
797
828
 
798
829
  const { documentElement: clone } = event.detail;
799
830
  if (!clone) return;
@@ -810,43 +841,33 @@ class LiveSync {
810
841
  // content its stamp will describe. Held for _relayCommit, and overwritten
811
842
  // by the next capture, so it always names the save currently in flight.
812
843
  this._savedSnapshot = { html, identityMap };
813
-
814
- this.sendUpdate(html, identityMap);
815
844
  };
816
845
 
817
846
  document.addEventListener('clay:snapshot-ready', this._snapshotHandler);
818
847
  }
819
848
 
820
849
  /**
821
- * Send full HTML to the server (debounced, single-flight).
850
+ * Send a landed save's HTML to the relay (single-flight).
822
851
  *
823
852
  * One POST on the wire at a time, with at most one newer payload queued; a
824
853
  * fresher snapshot replaces the queued one, because peers only ever need the
825
854
  * latest state. Two concurrent fetches could reach the server in either order,
826
855
  * and last-write-wins then stored the OLDER snapshot for every peer.
827
856
  */
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) {
857
+ _enqueueSend(html, identityMap, etag = null) {
837
858
  // Same one-deep queue the in-flight case uses, for the same reason: peers only
838
859
  // ever need the newest state, so a fresher snapshot replaces the waiting one.
839
860
  // Sending before the profile is known would have to guess an address.
840
861
  if (!this._profile) {
841
- this._queuedSend = { html, identityMap };
862
+ this._queuedSend = { html, identityMap, etag };
842
863
  this._resolveProfile().then(() => this._flushQueuedSend());
843
864
  return;
844
865
  }
845
866
  if (this._sendInFlight) {
846
- this._queuedSend = { html, identityMap };
867
+ this._queuedSend = { html, identityMap, etag };
847
868
  return;
848
869
  }
849
- this._postUpdate(html, identityMap);
870
+ this._postUpdate(html, identityMap, etag);
850
871
  }
851
872
 
852
873
  _flushQueuedSend() {
@@ -854,7 +875,7 @@ class LiveSync {
854
875
  const queued = this._queuedSend;
855
876
  if (!queued) return;
856
877
  this._queuedSend = null;
857
- this._postUpdate(queued.html, queued.identityMap);
878
+ this._postUpdate(queued.html, queued.identityMap, queued.etag);
858
879
  }
859
880
 
860
881
  /**
@@ -862,23 +883,14 @@ class LiveSync {
862
883
  *
863
884
  * Runs on clay:save-saved, by which point the save response has already been
864
885
  * 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.
886
+ * The content is `_savedSnapshot`, captured by that save's own snapshot-ready
887
+ * event, never `lastHtml`: a capture is not relayed until its save lands, so
888
+ * peers only ever see versions the host accepted, each descending from the one
889
+ * before. A host that returns no stamp still gets the relay, without one.
873
890
  */
874
891
  _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.
892
+ if (this.isDestroyed) return;
879
893
  const etag = lastSeenEtag();
880
- if (!etag) return;
881
- if (this.isDestroyed || this.isPaused) return;
882
894
 
883
895
  // The content this save stored, captured with it. Consumed rather than left
884
896
  // behind, so a save-saved with no capture of its own can never reuse an
@@ -886,51 +898,18 @@ class LiveSync {
886
898
  const pending = this._savedSnapshot;
887
899
  this._savedSnapshot = null;
888
900
 
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.
901
+ // No captured snapshot means nothing here knows which bytes this save
902
+ // stored, and §10 is explicit that a stamp must never travel on its own.
893
903
  if (!pending || typeof pending.html !== 'string') return;
894
904
 
895
- this._postCommit(pending.html, etag, pending.identityMap);
896
- }
897
-
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
- });
905
+ this._enqueueSend(pending.html, pending.identityMap, etag || null);
929
906
  }
930
907
 
931
- _postUpdate(html, identityMap) {
932
- // Skip if unchanged
933
- if (html === this.lastHtml) {
908
+ _postUpdate(html, identityMap, etag = null, attempt = 0) {
909
+ // Skip only an unstamped repeat. A landed save is news even when its bytes
910
+ // equal the last relay: "Keep mine" can restore exactly those bytes after
911
+ // the peers moved on, and they must hear it.
912
+ if (!etag && html === this.lastHtml) {
934
913
  this._log('Skipping send - HTML unchanged');
935
914
  return;
936
915
  }
@@ -939,6 +918,7 @@ class LiveSync {
939
918
 
940
919
  this._sendInFlight = true;
941
920
  const gen = this._applyGen;
921
+ let retry = false;
942
922
 
943
923
  // Absolute against the real origin, so a <base href> in the page cannot
944
924
  // redirect the whole document to an origin the author picked.
@@ -952,7 +932,8 @@ class LiveSync {
952
932
  body: JSON.stringify({
953
933
  [profile.snapshotKey]: html,
954
934
  sender: this.clientId,
955
- identityMap: identityMap
935
+ identityMap: identityMap,
936
+ ...(etag ? { etag } : {})
956
937
  })
957
938
  }).then(response => {
958
939
  if (response.ok) {
@@ -968,15 +949,33 @@ class LiveSync {
968
949
  }
969
950
  } else {
970
951
  console.warn('[LiveSync] Save returned status:', response.status);
952
+ retry = response.status >= 500 || response.status === 408 || response.status === 429;
971
953
  }
972
954
  }).catch(err => {
973
955
  console.error('[LiveSync] Save failed:', err);
974
956
  if (this.onError) this.onError(err);
957
+ retry = true;
975
958
  }).finally(() => {
976
959
  this._sendInFlight = false;
977
960
  const queued = this._queuedSend;
978
961
  this._queuedSend = null;
979
- if (queued) this._postUpdate(queued.html, queued.identityMap);
962
+ if (queued) {
963
+ this._postUpdate(queued.html, queued.identityMap, queued.etag);
964
+ return;
965
+ }
966
+ // The relay of a landed save is the only one peers get, so a transient
967
+ // failure is retried a few times. A newer save queued meanwhile replaces
968
+ // it: peers only need the latest.
969
+ if (!retry || !etag || attempt >= 3 || this.isDestroyed) return;
970
+ this._sendInFlight = true;
971
+ setTimeout(() => {
972
+ this._sendInFlight = false;
973
+ if (this.isDestroyed) return;
974
+ const newer = this._queuedSend;
975
+ this._queuedSend = null;
976
+ if (newer) this._postUpdate(newer.html, newer.identityMap, newer.etag);
977
+ else this._postUpdate(html, identityMap, etag, attempt + 1);
978
+ }, 500 * 2 ** attempt);
980
979
  });
981
980
  }
982
981
 
@@ -1445,6 +1444,7 @@ class LiveSync {
1445
1444
 
1446
1445
  let diverged = false;
1447
1446
  let typedDuringWait = false;
1447
+ let stamped = false;
1448
1448
  try {
1449
1449
  // Hold the whole frame only when this tab has unsaved edits and no
1450
1450
  // baseline to merge them against (the first frame of a fresh
@@ -1522,7 +1522,10 @@ class LiveSync {
1522
1522
  // above, and deliberately the same moment: the two claims a page makes
1523
1523
  // when it adopts a stamp are "the host stores this version" and "I hold
1524
1524
  // it", and only the second one is this tab's to make.
1525
- if (typeof etag === 'string' && etag) recordEtag(etag);
1525
+ if (typeof etag === 'string' && etag) {
1526
+ recordEtag(etag);
1527
+ stamped = true;
1528
+ }
1526
1529
 
1527
1530
  // Cross-lane baseline: the DOM now holds this frame's content, but the
1528
1531
  // DISK baseline (lastSavedContents) still describes pre-frame state. A
@@ -1593,9 +1596,12 @@ class LiveSync {
1593
1596
  // edits + their frame) that exists only in this DOM. Push it out explicitly
1594
1597
  // — the morph ran under Mutation.pause, so no autosave was triggered, and a
1595
1598
  // pending autosave debounce may already have fired mid-flight. Runs after
1596
- // isPaused is lifted so the save's snapshot-ready relay reaches peers.
1599
+ // isPaused is lifted so the convergence save's relay reaches peers.
1597
1600
  // Only where autosave is on: a manual-save page must never auto-write, so
1598
1601
  // there the merge stays local, still dirty, until the person saves.
1602
+ // Not over a lost conflict: the hold and its bar stay up as the signal that
1603
+ // this tab lost text, until the person saves.
1604
+ if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
1599
1605
  this._saveAfterMerge(diverged, typedDuringWait);
1600
1606
  }
1601
1607
 
@@ -1626,6 +1632,7 @@ class LiveSync {
1626
1632
 
1627
1633
  let diverged = false;
1628
1634
  let typedDuringWait = false;
1635
+ let stamped = false;
1629
1636
  try {
1630
1637
  // Hold the whole frame only when this tab has unsaved edits and no
1631
1638
  // baseline to merge them against. Lane-guarded like the peer path: a
@@ -1682,7 +1689,10 @@ class LiveSync {
1682
1689
  // A frame with no stamp (an older host, or the content-less fetch fallback,
1683
1690
  // which serves bytes nobody stamped) leaves this alone, and the listener in
1684
1691
  // etag.js falls back to asking the host.
1685
- if (typeof etag === 'string' && etag) recordEtag(etag);
1692
+ if (typeof etag === 'string' && etag) {
1693
+ recordEtag(etag);
1694
+ stamped = true;
1695
+ }
1686
1696
 
1687
1697
  if (
1688
1698
  this.lane === 'live' &&
@@ -1733,6 +1743,9 @@ class LiveSync {
1733
1743
  // (our edits + their bytes). The baseline was left pre-external, so the
1734
1744
  // save sees both as changes and writes the merge back. Only where
1735
1745
  // autosave is on, as in the peer lane.
1746
+ // Not over a lost conflict: the hold and its bar stay up as the signal that
1747
+ // this tab lost text, until the person saves.
1748
+ if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
1736
1749
  this._saveAfterMerge(diverged, typedDuringWait);
1737
1750
  }
1738
1751