@panphora/clayjs 1.3.0 → 1.5.0

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.
@@ -36,32 +36,115 @@
36
36
  */
37
37
 
38
38
  import { HyperMorph, morph } from "../vendor/hyper-morph.vendor.js";
39
+
40
+ const { createIdentityStore } = HyperMorph;
39
41
  import Mutation from "../lib/mutation.js";
40
- import { isSnapshotRemoved } from "../lib/region-policy.js";
42
+ import { NO_DIRTY_SELECTOR } from "../lib/region-policy.js";
43
+ import { EXTENSION_NODE_SELECTOR } from '../lib/extension-noise.js';
41
44
  import { isEditMode } from "../core/is-edit-mode.js";
42
45
  import { mergeTagRecognizers } from "./merge-tags.js";
43
- import { serializeForSync, captureForComparisonAndDirty, captureSnapshot, originalSnapshotNode } from '../core/snapshot.js';
44
- import { isTabLocalRootAttr } from '../lib/root-attrs.js';
45
- import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
46
+ import { serializeForSync, captureForComparisonAndDirty, captureSnapshot, originalSnapshotNode, captureForMerge, captureForSaveAndComparison } from '../core/snapshot.js';
47
+ import { isTabLocalRootAttr, TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
48
+ import { autosaveActive } from '../lib/autosave-state.js';
49
+ import { enableContentEditable } from '../core/admin-contenteditable.js';
50
+ import { enableOnClick } from '../core/admin-onclick.js';
51
+ import { enableAdminInputs } from '../core/admin-inputs.js';
52
+ import { enableAdminResources } from '../core/admin-resources.js';
46
53
  import { presence } from './presence.js';
47
54
  // Side-effect import: the section-changed notice wires itself to
48
55
  // `clay:sync-applied`, which this file is the only dispatcher of.
49
56
  import './section-notice.js';
50
57
  import { hostMeta } from '../core/host-meta.js';
51
58
  import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
52
- import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
59
+ import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged, gateMarkDirty } from '../lib/dirty-gate.js';
53
60
  import { SyncStream } from './stream.js';
54
61
 
62
+ // What a live-sync merge never reads or touches on any side: editor chrome,
63
+ // content kept out of the save or the snapshot, frozen regions, and nodes
64
+ // browser extensions inject. The same set the 0.5 morph's sync policy ignored.
65
+ const SYNC_IGNORE_SELECTOR = [
66
+ '[editor-ui]', '[clay~="editor-ui"]', '[save-ignore]', '[snapshot-remove]',
67
+ '[no-snapshot]', '[no-save]', '[save-remove]', '[freeze]', '[save-freeze]',
68
+ '[clay~="no-save"]', '[clay~="no-snapshot"]', '[clay~="freeze"]',
69
+ EXTENSION_NODE_SELECTOR,
70
+ ].join(',');
71
+
72
+ // Regions whose local state is never work to keep: disposable content
73
+ // (no-dirty) and regions the mutation system does not watch, whose churn the
74
+ // dirty gate already excludes. The incoming frame's version wins there.
75
+ const REMOTE_WINS_SELECTOR = [
76
+ NO_DIRTY_SELECTOR,
77
+ '[clay~="no-watch"]', '[no-watch]', '[mutations-ignore]',
78
+ ].join(',');
79
+
80
+ // Host tokens and library state on a document's root element, on every side
81
+ // of a merge: the live root, a snapshot clone's root, a parsed frame's root.
82
+ const isRootLocalAttr = (el, name) =>
83
+ !el.parentElement && el.localName === 'html' && TAB_LOCAL_ROOT_ATTRS.has(name);
84
+
85
+ // Identity an author wrote into the page, the fallback behind synthetic ids.
86
+ //
87
+ // Answered only where it is unique on its side. hyper-morph drops a duplicated
88
+ // id from its own index, but an identity answered here pre-empts the sender's
89
+ // map (`first`), so cards cloned from one template, all carrying the same
90
+ // data-id, lost their synthetic ids and paired by nothing while their children
91
+ // kept theirs and cross-paired. Answering null lets the map speak.
92
+ //
93
+ // `memo` caches one count per side tree for the length of one merge. It must
94
+ // not outlive the merge: a two-way call reads the live document as its local
95
+ // side, and the live document changes between frames.
96
+ const authoredAttr = (el) => el.getAttribute('data-id') || el.getAttribute('id') || null;
97
+
98
+ function countAuthored(root) {
99
+ const counts = new Map();
100
+ const visit = (el) => {
101
+ const id = authoredAttr(el);
102
+ if (id) counts.set(id, (counts.get(id) || 0) + 1);
103
+ const kids = el.localName === 'template' && el.content ? el.content.children : el.children;
104
+ for (const kid of kids) visit(kid);
105
+ };
106
+ const tops = root.nodeType === 9 ? [root.documentElement] : root.nodeType === 11 ? root.children : [root];
107
+ for (const top of tops) if (top && top.nodeType === 1) visit(top);
108
+ return counts;
109
+ }
110
+
111
+ const uniqueAuthoredIdentity = (memo) => (el) => {
112
+ const id = authoredAttr(el);
113
+ if (!id) return null;
114
+ const root = el.getRootNode();
115
+ let counts = memo.get(root);
116
+ if (!counts) memo.set(root, (counts = countAuthored(root)));
117
+ return counts.get(id) === 1 ? id : null;
118
+ };
119
+
120
+ const isIdMap = (m) => !!m && typeof m === 'object' && !Array.isArray(m);
121
+
55
122
  // The page just took a frame verified clean against its baseline, so it now IS
56
123
  // the file on disk. Both saved baselines move together from one capture: leaving
57
124
  // the dirty baseline behind would make the close warning fire on the frame's own
58
125
  // content. Never flushes undo (this runs per incoming frame, not per save) and
59
126
  // never emits snapshot-ready (it must not feed the send pipeline).
127
+ /**
128
+ * Convert an incoming disk document from its saved (inert) form to the live
129
+ * edit-mode form, exactly as boot does on page load, so the merge compares
130
+ * like with like: without this, every disk frame would swap the live page's
131
+ * contenteditable/onclick/admin state back to inert, killing the caret
132
+ * mid-edit. Root attributes (editmode, savestatus, tokens) need no reversal
133
+ * here; the merge's attribute veto keeps the live root's own.
134
+ */
135
+ function activateIncomingDoc(rootEl) {
136
+ if (!isEditMode || !rootEl) return;
137
+ enableContentEditable(rootEl);
138
+ enableOnClick(rootEl);
139
+ enableAdminInputs(rootEl);
140
+ enableAdminResources(rootEl);
141
+ }
142
+
60
143
  function pairedBaseline() {
61
144
  const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
62
145
  return [forComparison, forDirty];
63
146
  }
64
- import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges } from '../core/save.js';
147
+ import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes } from '../core/save.js';
65
148
 
66
149
  /**
67
150
  * The two live-sync wires, and the rule for choosing between them.
@@ -113,6 +196,27 @@ class LiveSync {
113
196
  this.sse = null;
114
197
  this.currentFile = null;
115
198
  this.lastHtml = null;
199
+ // The disk lane's merge base: the file's bytes, in the save domain, as of
200
+ // the most recent moment this tab's document agreed with the file. Three
201
+ // things are such a moment: the seed at start(), a frame's synchronous
202
+ // apply, and a save's capture. Each takes a ticket from _agreeSeq at that
203
+ // moment and writes through _setDiskBase, which keeps the newest moment:
204
+ // a save response landing after a later frame applied, or a frame whose
205
+ // await outlasts a later save, can no longer rewind the base.
206
+ this._diskBase = null;
207
+ this._diskBaseTicket = 0;
208
+ this._agreeSeq = 0;
209
+ // The ticket of the save currently in flight, taken when it captured.
210
+ this._saveTicket = 0;
211
+ // Conflicts an incoming frame won over this tab's unsaved text, kept until
212
+ // this tab's next successful save. While any are here the page stays
213
+ // dirty: no clean frame clears the gate or advances a saved baseline, and
214
+ // no automatic save runs from the merge path, so the close warning holds
215
+ // and the lost text stays reachable. Each entry is a hyper-morph conflict
216
+ // (`local` is the text this tab lost).
217
+ this.unresolvedConflicts = [];
218
+ // Each conflict's ticket: the moment its frame applied.
219
+ this._conflictTickets = new WeakMap();
116
220
  this.clientId = this.generateClientId();
117
221
 
118
222
  // Per-stream resume id for the htmlclay replay server's wire contract.
@@ -227,12 +331,11 @@ class LiveSync {
227
331
 
228
332
  // Identity tracking for content-based morphing across live-sync updates.
229
333
  // Synthetic IDs (`<clientId>:<counter>`) live here only — never written to
230
- // the DOM, never serialized into saved HTML. The WeakMap holds them
334
+ // the DOM, never serialized into saved HTML. hyper-morph’s identity store holds them
231
335
  // against the live elements so that the next save can produce the same
232
336
  // identityMap, and afterNodeMorphed transfers IDs from incoming parsed
233
337
  // elements onto the live elements they morphed into.
234
- this.idCounter = 0;
235
- this.liveWeakMap = new WeakMap();
338
+ this.identity = createIdentityStore(this.clientId);
236
339
 
237
340
  // Callbacks
238
341
  this.onConnect = null;
@@ -303,6 +406,12 @@ class LiveSync {
303
406
  // so mint a fresh resume id — this stream must not resume the previous one.
304
407
  this.lastHtml = null;
305
408
  this._lastIdentityMap = null;
409
+ // The reset takes a ticket of its own, so a writer from before this start
410
+ // (a save response still on the wire) cannot land in the new run.
411
+ this._diskBase = null;
412
+ this._diskBaseTicket = this._ticket();
413
+ this._saveTicket = 0;
414
+ this.unresolvedConflicts = [];
306
415
  this._savedSnapshot = null;
307
416
  this._applyGen++;
308
417
  this.lastSeenSeq = 0;
@@ -310,6 +419,17 @@ class LiveSync {
310
419
  this._pendingExternal = null;
311
420
  this.resumeId = this.generateResumeId();
312
421
 
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
+ }
432
+
313
433
  console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
314
434
  // One discovery request stands between here and the stream. It is memoized and
315
435
  // bounded, and it does not gate ordinary saves — /_/save is a separate lane that
@@ -327,6 +447,23 @@ class LiveSync {
327
447
  this.listenForSnapshots();
328
448
  this._saveSavedHandler = () => {
329
449
  this._saveEpoch++;
450
+ // The file now holds what this save wrote: the disk lane's new base,
451
+ // dated at the save's capture, which is when the document held these
452
+ // bytes. A frame that applied after that capture is newer and keeps
453
+ // the base. A save this instance never saw capture (there is none
454
+ // today) counts as now.
455
+ const ticket = this._saveTicket || this._ticket();
456
+ this._saveTicket = 0;
457
+ const bytes = getLastSavedBytes();
458
+ if (bytes != null) this._setDiskBase(bytes, ticket);
459
+ // A save this tab made is the one act that acknowledges a lost
460
+ // conflict: the person wrote the page as it stands. Only the page as
461
+ // it stood at the capture: a conflict from a frame that applied after
462
+ // it is not in these bytes, and it keeps the page dirty.
463
+ this.unresolvedConflicts = this.unresolvedConflicts.filter(
464
+ (c) => this._conflictTickets.get(c) > ticket
465
+ );
466
+ if (this.unresolvedConflicts.length) gateMarkDirty();
330
467
  this._relayCommit();
331
468
  };
332
469
  document.addEventListener('clay:save-saved', this._saveSavedHandler);
@@ -378,113 +515,6 @@ class LiveSync {
378
515
  this._holdRetryExt = null;
379
516
  }
380
517
 
381
- _mintId() {
382
- this.idCounter++;
383
- return `${this.clientId}:${this.idCounter}`;
384
- }
385
-
386
- /**
387
- * Walk the live DOM and the snapshot clone in lockstep. Path keys come
388
- * from the clone (= what the receiver will see, after [snapshot-remove]
389
- * and snapshotHooks). WeakMap lookup happens against the live element so
390
- * synthetic IDs persist across saves.
391
- *
392
- * The live walk filters [snapshot-remove] to mirror the clone's earlier
393
- * strip in captureSnapshot. If child counts diverge anywhere (an
394
- * onbeforesnapshot handler added/removed siblings on the clone), the
395
- * subtree is skipped — better to fall back to content scoring there than
396
- * emit misaligned IDs.
397
- *
398
- * @param {Element} liveRoot
399
- * @param {Element} cloneRoot
400
- * @returns {Object} identityMap keyed by dot-path
401
- */
402
- _buildIdentityMap(liveRoot, cloneRoot) {
403
- const map = {};
404
- if (!liveRoot || !cloneRoot) return map;
405
-
406
- const visit = (clone, path) => {
407
- const live = originalSnapshotNode(clone);
408
- if (live) {
409
- let id = this.liveWeakMap.get(live);
410
- if (!id) {
411
- id = this._mintId();
412
- this.liveWeakMap.set(live, id);
413
- }
414
- map[path] = id;
415
- }
416
-
417
- const cloneKids = clone.children;
418
-
419
- for (let i = 0; i < cloneKids.length; i++) {
420
- visit(cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
421
- }
422
- };
423
-
424
- visit(cloneRoot, '');
425
- return map;
426
- }
427
-
428
- /**
429
- * Walk a single parsed tree, invoking cb(element, path) at each Element.
430
- * Paths use the same dot-segment scheme as _buildIdentityMap so the
431
- * receiver can look up IDs by the path the sender emitted.
432
- */
433
- _walkParsedTree(root, cb) {
434
- if (!root) return;
435
- const visit = (el, path) => {
436
- cb(el, path);
437
- const kids = el.children;
438
- for (let i = 0; i < kids.length; i++) {
439
- visit(kids[i], path === '' ? String(i) : `${path}.${i}`);
440
- }
441
- };
442
- visit(root, '');
443
- }
444
-
445
- /**
446
- * Fill liveWeakMap entries for live elements that the matcher's
447
- * afterNodeMorphed didn't reach. createNode's no-id-children
448
- * optimization (hyper-morph importNode path) inserts a clone of the
449
- * parsed element without invoking morphNode, so afterNodeMorphed never
450
- * fires for those subtrees and their synthetic IDs would be lost. On
451
- * the receiver's next save, _buildIdentityMap would mint fresh IDs for
452
- * the same logical elements, breaking convergence for newly-added
453
- * ambiguous siblings — exactly the case identity-map exists to fix.
454
- *
455
- * Walks live and parsed in lockstep, reading ids off the parsed NODES
456
- * (parsedWeakMap) rather than re-deriving dot-paths: a protected splice
457
- * shifts paths, but the WeakMap entries ride the nodes and stay correct.
458
- * Filters [snapshot-remove] from the live side to stay aligned with the
459
- * sender's clone view. Aborts a subtree on child-count divergence (e.g.
460
- * local save-ignore additions) — those elements fall through to content
461
- * scoring on the next round, which is the same fallback as a sender-side
462
- * lockstep skip.
463
- *
464
- * @param {Element} liveRoot - post-morph live tree root
465
- * @param {Element} parsedRoot - parsed-tree root
466
- * @param {WeakMap} parsedWeakMap - parsed node → synthetic id
467
- */
468
- _fillInIdsAfterMorph(liveRoot, parsedRoot, parsedWeakMap) {
469
- if (!liveRoot || !parsedRoot || !parsedWeakMap) return;
470
- const visit = (live, parsed) => {
471
- const id = parsedWeakMap.get(parsed);
472
- if (id && !this.liveWeakMap.has(live)) {
473
- this.liveWeakMap.set(live, id);
474
- }
475
- const liveKids = [];
476
- for (const c of live.children) {
477
- if (!isSnapshotRemoved(c)) liveKids.push(c);
478
- }
479
- const parsedKids = parsed.children;
480
- if (liveKids.length !== parsedKids.length) return;
481
- for (let i = 0; i < liveKids.length; i++) {
482
- visit(liveKids[i], parsedKids[i]);
483
- }
484
- };
485
- visit(liveRoot, parsedRoot);
486
- }
487
-
488
518
  /**
489
519
  * Auto-detect the current site file path from the URL
490
520
  * Returns the path including extension (e.g., card-canvas.html)
@@ -755,6 +785,11 @@ class LiveSync {
755
785
  */
756
786
  listenForSnapshots() {
757
787
  this._snapshotHandler = (event) => {
788
+ // 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.
792
+ this._saveTicket = this._ticket();
758
793
  if (this.isPaused) {
759
794
  this._log('snapshot-ready received but isPaused, skipping');
760
795
  return;
@@ -769,7 +804,7 @@ class LiveSync {
769
804
  // must happen now.
770
805
  this._log('snapshot-ready received, preparing to send');
771
806
  const html = serializeForSync(clone);
772
- const identityMap = this._buildIdentityMap(document.documentElement, clone);
807
+ const identityMap = this.identity.exportMap(clone, originalSnapshotNode);
773
808
 
774
809
  // The save that follows this capture stores these bytes, so this is the
775
810
  // content its stamp will describe. Held for _relayCommit, and overwritten
@@ -1184,6 +1219,21 @@ class LiveSync {
1184
1219
  }
1185
1220
  }
1186
1221
 
1222
+ /** A ticket for one moment the document agreed with the file. Later is greater. */
1223
+ _ticket() {
1224
+ return ++this._agreeSeq;
1225
+ }
1226
+
1227
+ /**
1228
+ * Set the disk lane's base to `bytes`, dated at `ticket`, unless a later
1229
+ * moment already set it. Every writer goes through here; see the field.
1230
+ */
1231
+ _setDiskBase(bytes, ticket) {
1232
+ if (ticket <= this._diskBaseTicket) return;
1233
+ this._diskBase = bytes;
1234
+ this._diskBaseTicket = ticket;
1235
+ }
1236
+
1187
1237
  _requestFrame(cb) {
1188
1238
  if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
1189
1239
  return window.requestAnimationFrame(cb);
@@ -1199,6 +1249,175 @@ class LiveSync {
1199
1249
  clearTimeout(handle);
1200
1250
  }
1201
1251
 
1252
+ /**
1253
+ * Merge an incoming frame (a peer's broadcast or the file's bytes) into the
1254
+ * live document with hyper-morph, and return its report.
1255
+ *
1256
+ * With a base, the merge is three-way: the base the lane last agreed on, this
1257
+ * tab's state captured in the base's serialization domain, and the frame.
1258
+ * Both sides' edits survive; where both changed the same words the frame
1259
+ * wins and the report records a conflict. With no base (a clean tab before
1260
+ * its first seed), the frame is taken two-way. Callers hold before this when
1261
+ * the tab is dirty and has no base.
1262
+ *
1263
+ * Authored ids (data-id, id) outrank synthetic ones on every side, so a map
1264
+ * can never pair an element against a different element that carries the
1265
+ * same authored id.
1266
+ *
1267
+ * @param {string} html - the incoming frame
1268
+ * @param {object|null} identityMap - the sender's synthetic ids, if any
1269
+ * @param {object} lane
1270
+ * @param {string|null} lane.base - the lane's merge base
1271
+ * @param {object|null} [lane.baseIdentityMap] - synthetic ids for the base
1272
+ * @param {() => Element} lane.captureLocal - this tab's state in the base's domain
1273
+ * @param {boolean} lane.synthetic - whether this tab's synthetic ids apply
1274
+ * @param {object} [lane.extra] - extra mergeDocument options (the disk lane's beforeApply)
1275
+ */
1276
+ async _mergeIncoming(html, identityMap, { base, baseIdentityMap = null, captureLocal, synthetic, extra = {} }) {
1277
+ const store = this.identity;
1278
+ // A frame may neither write these onto our root nor, by not carrying them,
1279
+ // take ours away. Returning false is hyper-morph's veto for both directions.
1280
+ const beforeAttributeUpdated = (name, element) =>
1281
+ isTabLocalRootAttr(name, element) ? false : undefined;
1282
+ const authored = uniqueAuthoredIdentity(new WeakMap());
1283
+ const sideIdentity = (map) =>
1284
+ isIdMap(map) ? { first: authored, map, then: authored } : authored;
1285
+ const localIdentity = synthetic
1286
+ ? (el) => authored(el) || store.idOf(originalSnapshotNode(el) || el) || null
1287
+ : authored;
1288
+
1289
+ // Elements the frame names by authored id may carry a synthetic id in the
1290
+ // sender's map too, and this tab's element with that authored id has to
1291
+ // adopt it: a receiver on the older runtime keys its live elements by
1292
+ // synthetic id alone, and a frame from this tab that used a fresh synthetic
1293
+ // id for the same card would make that receiver replace the card, losing
1294
+ // its listeners and state. The frame is parsed here so the map can be
1295
+ // resolved against the same nodes hyper-morph will see; a Document is
1296
+ // taken as given, so nothing is parsed twice.
1297
+ let remote = html;
1298
+ const syntheticByAuthored = new Map();
1299
+ if (isIdMap(identityMap)) {
1300
+ remote = new DOMParser().parseFromString(html, 'text/html');
1301
+ const imported = HyperMorph.importMap(remote.documentElement, identityMap);
1302
+ for (const el of remote.querySelectorAll('[data-id], [id]')) {
1303
+ const id = authored(el);
1304
+ const synthetic = id && imported.get(el);
1305
+ if (synthetic) syntheticByAuthored.set(id, synthetic);
1306
+ }
1307
+ }
1308
+
1309
+ const common = {
1310
+ live: document,
1311
+ remote,
1312
+ ignore: (el) => el.matches(SYNC_IGNORE_SELECTOR),
1313
+ remoteWins: (el) => el.matches(REMOTE_WINS_SELECTOR),
1314
+ ignoreAttribute: isRootLocalAttr,
1315
+ protectFocusedValue: true,
1316
+ scripts: { mergeTags: mergeTagRecognizers },
1317
+ hooks: { beforeAttributeUpdated },
1318
+ ...extra,
1319
+ };
1320
+ // The gate token is taken before the capture so a clear can never swallow
1321
+ // typing that lands during the merge. It is also how typing during the
1322
+ // await is noticed at all: the mutation feed is paused for the apply and
1323
+ // vacuumed on resume, so nothing else schedules a save for it.
1324
+ const dirty = this.lane === 'live' && pageMaybeDirty();
1325
+ const token = gateCaptureToken();
1326
+ // We MUST await: the Promise resolves once new external scripts have
1327
+ // loaded and run. Resuming mutations earlier would let late scripts' DOM
1328
+ // changes look like user edits and echo back out.
1329
+ let pending;
1330
+ if (base == null) {
1331
+ pending = HyperMorph.mergeDocument({
1332
+ ...common,
1333
+ base: null,
1334
+ identity: { local: localIdentity, remote: sideIdentity(identityMap) },
1335
+ });
1336
+ } else if (!dirty) {
1337
+ // A clean tab has no local changes, so its own capture stands in for the
1338
+ // base too: base and local are one tree. Merging the lane's recorded
1339
+ // base against a fresh capture would instead report every place the
1340
+ // live DOM does not round-trip through serialization (a raw <noscript>,
1341
+ // a <div> the parser lifted out of a <p>, a <table> given a <tbody>) as
1342
+ // a local edit, and a clean autosave tab would write that "edit" back to
1343
+ // disk on every frame. Still three-way: merge tags keep their base for
1344
+ // the JSON merge, and hyper-morph's clean-side shortcuts apply.
1345
+ const localRoot = captureLocal();
1346
+ const baseDoc = localRoot.ownerDocument;
1347
+ if (baseDoc.documentElement !== localRoot) baseDoc.replaceChild(localRoot, baseDoc.documentElement);
1348
+ pending = HyperMorph.mergeDocument({
1349
+ ...common,
1350
+ base: baseDoc,
1351
+ local: { root: localRoot, toLive: originalSnapshotNode },
1352
+ identity: {
1353
+ base: localIdentity,
1354
+ local: localIdentity,
1355
+ remote: sideIdentity(identityMap),
1356
+ },
1357
+ });
1358
+ } else {
1359
+ pending = HyperMorph.mergeDocument({
1360
+ ...common,
1361
+ base,
1362
+ local: { root: captureLocal(), toLive: originalSnapshotNode },
1363
+ identity: {
1364
+ base: sideIdentity(baseIdentityMap),
1365
+ local: localIdentity,
1366
+ remote: sideIdentity(identityMap),
1367
+ },
1368
+ });
1369
+ }
1370
+ // The apply itself ran synchronously inside mergeDocument; only resource
1371
+ // loads are still pending. This is the moment the frame's bytes were true
1372
+ // of the document, so the lane dates its base here, not after the await:
1373
+ // a save that captures during the wait is newer than this frame.
1374
+ const ticket = this._ticket();
1375
+ const report = await pending;
1376
+ const typedDuringWait = gateCaptureToken().gen !== token.gen;
1377
+
1378
+ // A conflict the frame won leaves this tab's text in report.conflicts and
1379
+ // nowhere else. It is kept until this tab's next successful save, so the
1380
+ // page stays dirty (the close warning holds) however many clean frames
1381
+ // follow, and nothing writes the page out from under it automatically.
1382
+ if (dirty && report.conflicts.length) {
1383
+ for (const c of report.conflicts) this._conflictTickets.set(c, ticket);
1384
+ this.unresolvedConflicts.push(...report.conflicts);
1385
+ }
1386
+ // Clean only when nothing of this tab's survived, nothing of it was lost,
1387
+ // and nothing lost earlier is still waiting on a save.
1388
+ if (
1389
+ dirty &&
1390
+ !report.localDiverged &&
1391
+ report.conflicts.length === 0 &&
1392
+ this.unresolvedConflicts.length === 0
1393
+ ) {
1394
+ gateClearIfUnchanged(token);
1395
+ }
1396
+ // Synthetic ids converge: every live element the frame named takes the
1397
+ // sender's id, whether the frame named it by that id or by an authored id
1398
+ // the sender's map also covers. Never an authored data-id or id itself.
1399
+ const mapIds = new Set(isIdMap(identityMap) ? Object.values(identityMap) : []);
1400
+ for (const [el, id] of report.identities) {
1401
+ const synthetic = mapIds.has(id) ? id : syntheticByAuthored.get(id);
1402
+ if (synthetic) store.adopt(el, synthetic);
1403
+ }
1404
+ return { report, ticket, typedDuringWait };
1405
+ }
1406
+
1407
+ /**
1408
+ * The automatic save a merge may owe: the merge kept this tab's edits
1409
+ * (convergence), or the person typed during the awaited apply (the mutation
1410
+ * feed was paused, so nothing else will schedule it). Only where autosave is
1411
+ * on, and never over an unresolved conflict: that save would write the page
1412
+ * with the lost text gone and clear the one signal that says so.
1413
+ */
1414
+ _saveAfterMerge(diverged, typedDuringWait) {
1415
+ if (!(diverged || typedDuringWait)) return;
1416
+ if (!autosaveActive()) return;
1417
+ if (this.unresolvedConflicts.length) return;
1418
+ savePageThrottled();
1419
+ }
1420
+
1202
1421
  /**
1203
1422
  * Actual morph work. Do not call directly. Use applyUpdate() so calls
1204
1423
  * pass through the rAF queue and don't overlap.
@@ -1224,85 +1443,34 @@ class LiveSync {
1224
1443
  Mutation.pause();
1225
1444
  pauseGate();
1226
1445
 
1227
- // Parse as full document
1228
- const parser = new DOMParser();
1229
- const newDoc = parser.parseFromString(html, 'text/html');
1230
-
1231
- // Build the parsed-tree WeakMap from the incoming identityMap. The
1232
- // sender emitted paths off its clone, which is exactly what we just
1233
- // parsed, so the same path scheme indexes into both trees.
1234
- const parsedWeakMap = new WeakMap();
1235
- if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
1236
- this._walkParsedTree(newDoc.documentElement, (el, path) => {
1237
- const id = identityMap[path];
1238
- if (id) parsedWeakMap.set(el, id);
1239
- });
1240
- }
1241
-
1242
- const liveWeakMap = this.liveWeakMap;
1243
- // Priority: synthetic IDs win when present (they're updated after every
1244
- // morph via afterNodeMorphed). data-id / id is the durable fallback that
1245
- // covers the bootstrap window and any element that hasn't been paired yet.
1246
- const key = (el) =>
1247
- liveWeakMap.get(el) ||
1248
- parsedWeakMap.get(el) ||
1249
- (el.getAttribute && el.getAttribute('data-id')) ||
1250
- (el.getAttribute && el.getAttribute('id')) ||
1251
- null;
1252
- const afterNodeMorphed = (oldEl, newEl) => {
1253
- const id = parsedWeakMap.get(newEl);
1254
- if (id) liveWeakMap.set(oldEl, id);
1255
- };
1256
- // A peer may neither write these onto our root nor, by not carrying them,
1257
- // take ours away. Returning false is hyper-morph's veto for both directions
1258
- // (it calls this for updateType 'update' and 'remove' alike).
1259
- const beforeAttributeUpdated = (name, element) =>
1260
- isTabLocalRootAttr(name, element) ? false : undefined;
1261
-
1262
- let retainedRoots = 0;
1446
+ let diverged = false;
1447
+ let typedDuringWait = false;
1263
1448
  try {
1264
- // Scoped sync: when this tab might hold unsaved edits, splice them into
1265
- // the incoming document BEFORE the morph so it cannot clobber them.
1266
- // The clean path skips every capture and stays byte-identical to a
1267
- // plain full morph.
1268
- if (this.lane === 'live' && pageMaybeDirty()) {
1269
- const protection = protectPeerDoc({
1270
- newDoc,
1271
- parsedWeakMap,
1272
- baseHtml: this.lastHtml,
1273
- baseIdentityMap: this._lastIdentityMap,
1274
- liveWeakMap: this.liveWeakMap,
1275
- });
1276
- if (!protection.ok) {
1277
- // Hold the whole frame: a dirty section couldn't be safely merged
1278
- // (or no baseline exists yet). Nothing morphs and no baseline
1279
- // moves; the tab keeps its local state and converges through its
1280
- // own next save. Deliberately NO proactive save here — a hold can
1281
- // fire on a manual-save page, which must never auto-write. The
1282
- // retry re-queues the same frame so it still applies if the
1283
- // blocking edit is undone; the slot-empty check and the drain's
1284
- // staleness checks drop it once superseded.
1285
- console.log('[LiveSync] Holding incoming update: unsaved local section cannot be safely merged', protection.held?.el || '');
1286
- this._setHeld('live', true, protection.held?.el);
1287
- const epochAtHold = this._saveEpoch;
1288
- const seenAtHold = this.lastSeenSeq;
1289
- clearTimeout(this._holdRetryPeer);
1290
- this._holdRetryPeer = setTimeout(() => {
1291
- this._holdRetryPeer = null;
1292
- if (this.isDestroyed || this._pendingHtml != null) return;
1293
- // Only while the world hasn't moved: an own save or any newer
1294
- // frame since the hold makes this payload stale.
1295
- if (this._saveEpoch !== epochAtHold) return;
1296
- if (this.lastSeenSeq !== seenAtHold) return;
1297
- this._pendingHtml = html;
1298
- this._pendingSeq = seq;
1299
- this._pendingIdentityMap = identityMap;
1300
- this._pendingBy = by;
1301
- this._scheduleNextFrame();
1302
- }, 3000);
1303
- return;
1304
- }
1305
- retainedRoots = protection.entries.length;
1449
+ // Hold the whole frame only when this tab has unsaved edits and no
1450
+ // baseline to merge them against (the first frame of a fresh
1451
+ // connection). Nothing morphs and no baseline moves; the tab keeps its
1452
+ // local state. Deliberately NO proactive save here: a hold can fire on a
1453
+ // manual-save page, which must never auto-write. The retry re-queues the
1454
+ // same frame; the slot-empty check and the drain's staleness checks drop
1455
+ // it once superseded.
1456
+ if (this.lane === 'live' && pageMaybeDirty() && this.lastHtml === null) {
1457
+ console.log('[LiveSync] Holding incoming update: unsaved local edits and no baseline to merge against');
1458
+ this._setHeld('live', true, null);
1459
+ const epochAtHold = this._saveEpoch;
1460
+ const seenAtHold = this.lastSeenSeq;
1461
+ clearTimeout(this._holdRetryPeer);
1462
+ this._holdRetryPeer = setTimeout(() => {
1463
+ this._holdRetryPeer = null;
1464
+ if (this.isDestroyed || this._pendingHtml != null) return;
1465
+ if (this._saveEpoch !== epochAtHold) return;
1466
+ if (this.lastSeenSeq !== seenAtHold) return;
1467
+ this._pendingHtml = html;
1468
+ this._pendingSeq = seq;
1469
+ this._pendingIdentityMap = identityMap;
1470
+ this._pendingBy = by;
1471
+ this._scheduleNextFrame();
1472
+ }, 3000);
1473
+ return;
1306
1474
  }
1307
1475
 
1308
1476
  this._setHeld('live', false);
@@ -1319,50 +1487,25 @@ class LiveSync {
1319
1487
  // knowingly missing what disk holds, and a stamp there would let its next
1320
1488
  // save replace that change with nobody told.
1321
1489
 
1322
- // Morph entire document. We MUST await — HyperMorph.morph returns a
1323
- // Promise when `scripts: { handle: true }` needs to wait for external
1324
- // scripts to load. If we don't await, Mutation.resume() fires before
1325
- // late-loading scripts execute, and any DOM mutations they trigger look
1326
- // like user edits → the receiving tab rebroadcasts them (feedback loop).
1327
- await HyperMorph.morph(document.documentElement, newDoc.documentElement, {
1328
- morphStyle: 'outerHTML',
1329
- ignoreActiveValue: true,
1330
- head: { style: 'merge' },
1331
- // mergeBase: mergeable script tags ([merge] + rules tags) three-way
1332
- // merge against the last synced state instead of being clobbered by
1333
- // the incoming save; lastHtml is exactly that base (set after every
1334
- // own save and every applied morph). Null on the first update →
1335
- // two-way merge, which still keeps local-only keys.
1336
- scripts: {
1337
- handle: true,
1338
- matchMode: 'smart',
1339
- mergeBase: this.lastHtml,
1340
- mergeTags: mergeTagRecognizers
1341
- },
1342
- key,
1343
- callbacks: { afterNodeMorphed, beforeAttributeUpdated }
1490
+ const { report, typedDuringWait: typed } = await this._mergeIncoming(html, identityMap, {
1491
+ base: this.lastHtml,
1492
+ baseIdentityMap: this._lastIdentityMap,
1493
+ captureLocal: () => captureSnapshot({ flushUndo: false }),
1494
+ synthetic: true,
1344
1495
  });
1496
+ diverged = report.localDiverged;
1497
+ typedDuringWait = typed;
1345
1498
 
1346
1499
  // Restore viewport. Done after morph so layout has settled.
1347
1500
  window.scrollTo(scrollX, scrollY);
1348
1501
 
1349
- // Fill in any IDs the matcher's afterNodeMorphed missed. Brand-new
1350
- // elements come in via hyper-morph's importNode optimization, which
1351
- // skips morphNode and thus afterNodeMorphed; their parsedWeakMap IDs
1352
- // never make it onto liveWeakMap. Without this pass, the receiver
1353
- // would mint fresh IDs on its next save for those elements,
1354
- // breaking convergence exactly for newly-added ambiguous siblings.
1355
- if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
1356
- this._fillInIdsAfterMorph(document.documentElement, newDoc.documentElement, parsedWeakMap);
1357
- }
1358
-
1359
1502
  // Only mark lastHtml after a successful morph so that a failed apply
1360
1503
  // doesn't desync our state and cause the next outbound save to be
1361
1504
  // mistakenly skipped as "unchanged". Note: lastSeenSeq is advanced at
1362
1505
  // receive time (in onmessage) so the staleness check covers own-save
1363
1506
  // echoes even when they don't reach this point.
1364
1507
  //
1365
- // lastHtml is the RAW incoming frame even after a protected apply. A
1508
+ // lastHtml is the RAW incoming frame even after a merge that kept local edits. A
1366
1509
  // patched serialization would poison the next frame's diff base (frame
1367
1510
  // two of a burst would read the protected section as clean and clobber
1368
1511
  // it) and could dedupe away the convergence send. Convergence is driven
@@ -1390,8 +1533,31 @@ class LiveSync {
1390
1533
  // baseline advances here too. Skipped whenever the gate reports dirty
1391
1534
  // (including typing that arrived during the morph's async wait, which
1392
1535
  // must never be recorded as saved).
1393
- if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
1394
- setLastSavedBaselines(...pairedBaseline());
1536
+ //
1537
+ // The same moment moves the DISK LANE's base: the document now agrees
1538
+ // with what the file holds (a peer frame carries committed content, and
1539
+ // this tab kept nothing of its own over it), so the file's bytes, as this
1540
+ // tab would save them, are the disk lane's new base. Without this the
1541
+ // disk lane kept a base from before the peer's edit, and the next disk
1542
+ // frame read the peer's content as this tab's own edit and resurrected it
1543
+ // over a newer revert. Never taken over unsaved local changes: a dirty
1544
+ // gate or a diverged merge leaves the base where it was, and the
1545
+ // convergence save moves it once the merge is on disk.
1546
+ //
1547
+ // Unresolved conflicts keep every saved baseline where it is too: the
1548
+ // page is dirty by definition until this tab saves.
1549
+ if (
1550
+ this.lane === 'live' &&
1551
+ !diverged &&
1552
+ !pageMaybeDirty() &&
1553
+ this.unresolvedConflicts.length === 0
1554
+ ) {
1555
+ const { forSave, forComparison, forDirty } = captureForSaveAndComparison({
1556
+ emitForSync: false,
1557
+ flushUndo: false,
1558
+ });
1559
+ setLastSavedBaselines(forComparison, forDirty);
1560
+ this._setDiskBase(forSave, this._ticket());
1395
1561
  }
1396
1562
 
1397
1563
  // Announce that a remote morph just landed, so document-level listeners
@@ -1411,7 +1577,7 @@ class LiveSync {
1411
1577
  // returns above without reaching here, so nothing can name an author for a
1412
1578
  // change this tab never took. Null on every frame the host did not stamp.
1413
1579
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1414
- detail: { seq, source: 'peer', by: by || null }
1580
+ detail: { seq, source: 'peer', by: by || null, report }
1415
1581
  }));
1416
1582
  } finally {
1417
1583
  this._log('applyUpdate - morph complete, resuming mutations');
@@ -1423,22 +1589,22 @@ class LiveSync {
1423
1589
  this.isPaused = false;
1424
1590
  }
1425
1591
 
1426
- // Convergence: a protected apply produced a merged state (our sections +
1427
- // their frame) that exists only in this DOM. Push it out explicitly — the
1428
- // morph ran under Mutation.pause, so no autosave was triggered, and a
1592
+ // Convergence: a merge that kept this tab's edits produced a state (our
1593
+ // edits + their frame) that exists only in this DOM. Push it out explicitly
1594
+ // — the morph ran under Mutation.pause, so no autosave was triggered, and a
1429
1595
  // pending autosave debounce may already have fired mid-flight. Runs after
1430
1596
  // isPaused is lifted so the save's snapshot-ready relay reaches peers.
1431
- if (retainedRoots > 0) {
1432
- savePageThrottled();
1433
- }
1597
+ // Only where autosave is on: a manual-save page must never auto-write, so
1598
+ // there the merge stays local, still dirty, until the person saves.
1599
+ this._saveAfterMerge(diverged, typedDuringWait);
1434
1600
  }
1435
1601
 
1436
1602
  /**
1437
1603
  * Apply an external disk change to this edit-mode tab. Same shape as
1438
- * _doApplyUpdate with three differences: the incoming document is save
1439
- * domain, so it is edit-mode ACTIVATED before the morph (inert attribute
1440
- * forms flipped live, as boot does on page load); dirty protection diffs
1441
- * against the save baseline instead of lastHtml; and on a clean apply the
1604
+ * _doApplyUpdate with two differences: the incoming document is save
1605
+ * domain, so the merged document is edit-mode ACTIVATED before it applies
1606
+ * (inert attribute forms flipped live, as boot does on page load); and on a
1607
+ * clean apply the
1442
1608
  * save baseline advances to a post-morph local comparison capture — true by
1443
1609
  * construction, where any wire-derived baseline permanently mismatches
1444
1610
  * (token, doctype, transform and parse divergences). A dirty apply leaves
@@ -1458,85 +1624,72 @@ class LiveSync {
1458
1624
  Mutation.pause();
1459
1625
  pauseGate();
1460
1626
 
1461
- let retainedRoots = 0;
1627
+ let diverged = false;
1628
+ let typedDuringWait = false;
1462
1629
  try {
1463
- const parser = new DOMParser();
1464
- const newDoc = parser.parseFromString(html, 'text/html');
1465
-
1466
- // Lane-guarded exactly like the peer path. A view-mode tab has no save
1467
- // baseline — every writer of lastSavedContents is edit-gated — so
1468
- // protectDiskDoc can only ever refuse, and the frame would hold, retry
1469
- // every 3s, and hold again forever. The gate still reads dirty there,
1470
- // because persistProbeDirty inspects the live DOM and a visitor can type
1471
- // into a [persist] field. Before the resync repair this path was
1472
- // unreachable outside the live lane; now it is the repair's own route.
1473
- if (this.lane === 'live' && pageMaybeDirty()) {
1474
- const protection = protectDiskDoc({ newDoc });
1475
- if (!protection.ok) {
1476
- // Hold: nothing morphs, no baseline moves, and deliberately NO
1477
- // proactive save (a hold can fire on a manual-save page, which
1478
- // must never auto-write). The frame's seq watermark has already
1479
- // advanced, so nothing redelivers it on its own; the retry
1480
- // re-queues it with the epoch captured NOW, so the drain's epoch
1481
- // check turns an intervening own save into a refetch of current
1482
- // disk instead of a stale re-apply, and the seq check drops it
1483
- // once a newer external change supersedes it.
1484
- console.log('[LiveSync] Holding external change: unsaved local section cannot be safely merged', protection.held?.el || '');
1485
- this._setHeld('external', true, protection.held?.el);
1486
- const epochAtHold = this._saveEpoch;
1487
- clearTimeout(this._holdRetryExt);
1488
- this._holdRetryExt = setTimeout(() => {
1489
- this._holdRetryExt = null;
1490
- if (this.isDestroyed || this._pendingExternal != null) return;
1491
- this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag, by };
1492
- this._scheduleNextFrame();
1493
- }, 3000);
1494
- return;
1495
- }
1496
- retainedRoots = protection.entries.length;
1630
+ // Hold the whole frame only when this tab has unsaved edits and no
1631
+ // baseline to merge them against. Lane-guarded like the peer path: a
1632
+ // view-mode tab's gate can still read dirty (a visitor typing into a
1633
+ // [persist] field), and it must take disk frames, never hold them.
1634
+ // Nothing morphs, no baseline moves, and deliberately NO proactive save
1635
+ // (a hold can fire on a manual-save page, which must never auto-write).
1636
+ // The frame's seq watermark has already advanced, so nothing redelivers
1637
+ // it on its own; the retry re-queues it with the epoch captured NOW, so
1638
+ // the drain's epoch check turns an intervening own save into a refetch
1639
+ // of current disk instead of a stale re-apply, and the seq check drops
1640
+ // it once a newer external change supersedes it.
1641
+ if (this.lane === 'live' && pageMaybeDirty() && this._diskBase === null) {
1642
+ console.log('[LiveSync] Holding external change: unsaved local edits and no baseline to merge against');
1643
+ this._setHeld('external', true, null);
1644
+ const epochAtHold = this._saveEpoch;
1645
+ clearTimeout(this._holdRetryExt);
1646
+ this._holdRetryExt = setTimeout(() => {
1647
+ this._holdRetryExt = null;
1648
+ if (this.isDestroyed || this._pendingExternal != null) return;
1649
+ this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag, by };
1650
+ this._scheduleNextFrame();
1651
+ }, 3000);
1652
+ return;
1497
1653
  }
1498
1654
 
1499
1655
  this._setHeld('external', false);
1500
1656
 
1501
- activateIncomingDoc(newDoc.documentElement);
1502
-
1503
- const liveWeakMap = this.liveWeakMap;
1504
- const key = (el) =>
1505
- liveWeakMap.get(el) ||
1506
- (el.getAttribute && el.getAttribute('data-id')) ||
1507
- (el.getAttribute && el.getAttribute('id')) ||
1508
- null;
1509
- const beforeAttributeUpdated = (name, element) =>
1510
- isTabLocalRootAttr(name, element) ? false : undefined;
1511
-
1512
- await HyperMorph.morph(document.documentElement, newDoc.documentElement, {
1513
- morphStyle: 'outerHTML',
1514
- ignoreActiveValue: true,
1515
- head: { style: 'merge' },
1516
- scripts: {
1517
- handle: true,
1518
- matchMode: 'smart',
1519
- mergeBase: this.lastHtml,
1520
- mergeTags: mergeTagRecognizers
1521
- },
1522
- key,
1523
- callbacks: { beforeAttributeUpdated }
1657
+ // All three sides are in the save domain, the file's own inert form: the
1658
+ // bytes this tab last saved or applied, this tab's save capture, and the
1659
+ // file. The merged document is activated for edit mode before it is
1660
+ // applied, exactly as boot would have, so every node, local or incoming,
1661
+ // goes live from its own saved form.
1662
+ const { report, ticket, typedDuringWait: typed } = await this._mergeIncoming(html, null, {
1663
+ base: this._diskBase,
1664
+ captureLocal: () => captureForMerge().saveClone,
1665
+ synthetic: false,
1666
+ extra: { beforeApply: (doc) => activateIncomingDoc(doc.documentElement) },
1524
1667
  });
1668
+ diverged = report.localDiverged;
1669
+ typedDuringWait = typed;
1670
+ // The file holds these bytes and this tab has applied them. Dated at the
1671
+ // apply, so a save that captured during the await keeps its newer base.
1672
+ this._setDiskBase(html, ticket);
1525
1673
 
1526
1674
  window.scrollTo(scrollX, scrollY);
1527
1675
 
1528
1676
  // The stamp of section 6, adopted here and nowhere else: this is the moment
1529
- // the bytes it describes reached this tab. It is taken on the retained-roots
1530
- // path too, because a merge still incorporates the disk bytes, and the
1531
- // convergence save at the bottom needs a stamp the host will accept or the
1532
- // merge is refused and lost.
1677
+ // the bytes it describes reached this tab. It is taken after a merge that
1678
+ // kept local edits too, because the merge still incorporates the disk
1679
+ // bytes, and the convergence save at the bottom needs a stamp the host
1680
+ // will accept or the merge is refused and lost.
1533
1681
  //
1534
1682
  // A frame with no stamp (an older host, or the content-less fetch fallback,
1535
1683
  // which serves bytes nobody stamped) leaves this alone, and the listener in
1536
1684
  // etag.js falls back to asking the host.
1537
1685
  if (typeof etag === 'string' && etag) recordEtag(etag);
1538
1686
 
1539
- if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
1687
+ if (
1688
+ this.lane === 'live' &&
1689
+ !diverged &&
1690
+ !pageMaybeDirty() &&
1691
+ this.unresolvedConflicts.length === 0
1692
+ ) {
1540
1693
  // Clean apply: the DOM now IS the disk state, so a local comparison
1541
1694
  // capture of it is the truthful baseline. The next no-op save skips,
1542
1695
  // beforeunload stays quiet. The dirty re-check matters: typing that
@@ -1552,7 +1705,7 @@ class LiveSync {
1552
1705
  // does, so it stays in the snapshot domain.
1553
1706
  const clone = captureSnapshot({ flushUndo: false });
1554
1707
  this.lastHtml = serializeForSync(clone);
1555
- this._lastIdentityMap = this._buildIdentityMap(document.documentElement, clone);
1708
+ this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
1556
1709
  this._applyGen++;
1557
1710
  }
1558
1711
 
@@ -1562,6 +1715,10 @@ class LiveSync {
1562
1715
  source: 'disk',
1563
1716
  etag: typeof etag === 'string' && etag ? etag : null,
1564
1717
  by: by || null,
1718
+ // The bytes now on disk, exactly as they arrived. The source map models them
1719
+ // so this tab's next save copies from the file somebody else just wrote.
1720
+ html,
1721
+ report,
1565
1722
  }
1566
1723
  }));
1567
1724
  } finally {
@@ -1572,12 +1729,11 @@ class LiveSync {
1572
1729
  this.isPaused = false;
1573
1730
  }
1574
1731
 
1575
- // Convergence: disk holds the writer's version, this DOM holds the merge.
1576
- // The baseline was left pre-external, so the save sees both our retained
1577
- // sections and the external content as changes and writes the merge back.
1578
- if (retainedRoots > 0) {
1579
- savePageThrottled();
1580
- }
1732
+ // Convergence: disk holds the writer's version, this DOM holds the merge
1733
+ // (our edits + their bytes). The baseline was left pre-external, so the
1734
+ // save sees both as changes and writes the merge back. Only where
1735
+ // autosave is on, as in the peer lane.
1736
+ this._saveAfterMerge(diverged, typedDuringWait);
1581
1737
  }
1582
1738
 
1583
1739
  /**