@panphora/clayjs 1.5.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.5.0",
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