@panphora/clayjs 0.4.3 → 0.6.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.
@@ -40,8 +40,11 @@ import Mutation from "../lib/mutation.js";
40
40
  import { isSnapshotRemoved } from "../lib/region-policy.js";
41
41
  import { isEditMode } from "../core/is-edit-mode.js";
42
42
  import { mergeTagRecognizers } from "./merge-tags.js";
43
- import { serializeForSync } from '../core/snapshot.js';
43
+ import { serializeForSync, captureForComparison, captureSnapshot } from '../core/snapshot.js';
44
44
  import { isTabLocalRootAttr } from '../lib/root-attrs.js';
45
+ import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
46
+ import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
47
+ import { savePageThrottled, setLastSavedContents, setUnsavedChanges } from '../core/save.js';
45
48
 
46
49
  class LiveSync {
47
50
  constructor() {
@@ -94,6 +97,38 @@ class LiveSync {
94
97
  this._morphInFlight = false;
95
98
  this._rafHandle = null;
96
99
 
100
+ // Disk-sourced external changes (htmlclay watcher content) get their own
101
+ // one-deep slot so a peer frame arriving in the same window can't
102
+ // displace them. Drained in seq order alongside the peer slot.
103
+ this._pendingExternal = null; // { html, seq, saveEpoch }
104
+
105
+ // Newest external-change seq accepted for apply. Replayed or reordered
106
+ // disk frames (and their fetch fallbacks) are dropped against this.
107
+ this._lastExternalSeq = 0;
108
+
109
+ // Bumped on every own save success. A queued disk frame older than our
110
+ // own landed save is stale by construction: the save just made disk
111
+ // equal our state.
112
+ this._saveEpoch = 0;
113
+ this._saveSavedHandler = null;
114
+
115
+ // The identityMap that came with lastHtml, so the peer-lane dirty diff
116
+ // can resolve synthetic identities on its base tree.
117
+ this._lastIdentityMap = null;
118
+
119
+ // Bumped whenever an APPLY (or a reset) rewrites lastHtml. A POST's
120
+ // success callback carries no ordering guarantee against the SSE stream,
121
+ // so a delayed response must not rewind lastHtml past a frame that
122
+ // applied while it was on the wire.
123
+ this._applyGen = 0;
124
+
125
+ // Hold-retry timers, one per pending slot. A held frame's slot and seq
126
+ // watermark have already advanced, so nothing redelivers it; the retry
127
+ // re-queues the same payload so it applies if the blocking local edit is
128
+ // undone, and is dropped by the seq/epoch checks if it went stale.
129
+ this._holdRetryPeer = null;
130
+ this._holdRetryExt = null;
131
+
97
132
  // Identity tracking for content-based morphing across live-sync updates.
98
133
  // Synthetic IDs (`<clientId>:<counter>`) live here only — never written to
99
134
  // the DOM, never serialized into saved HTML. The WeakMap holds them
@@ -171,7 +206,11 @@ class LiveSync {
171
206
  // Reset state for new connection. Resetting lastSeenSeq is a new baseline,
172
207
  // so mint a fresh resume id — this stream must not resume the previous one.
173
208
  this.lastHtml = null;
209
+ this._lastIdentityMap = null;
210
+ this._applyGen++;
174
211
  this.lastSeenSeq = 0;
212
+ this._lastExternalSeq = 0;
213
+ this._pendingExternal = null;
175
214
  this.resumeId = this.generateResumeId();
176
215
 
177
216
  console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
@@ -180,6 +219,8 @@ class LiveSync {
180
219
  // snapshot listener would never fire — skip registering it.
181
220
  if (this.lane === 'live') {
182
221
  this.listenForSnapshots();
222
+ this._saveSavedHandler = () => { this._saveEpoch++; };
223
+ document.addEventListener('clay:save-saved', this._saveSavedHandler);
183
224
  }
184
225
  }
185
226
 
@@ -198,6 +239,11 @@ class LiveSync {
198
239
  this._snapshotHandler = null;
199
240
  }
200
241
 
242
+ if (this._saveSavedHandler) {
243
+ document.removeEventListener('clay:save-saved', this._saveSavedHandler);
244
+ this._saveSavedHandler = null;
245
+ }
246
+
201
247
  clearTimeout(this.debounceTimer);
202
248
  this._queuedSend = null;
203
249
 
@@ -211,6 +257,11 @@ class LiveSync {
211
257
  this._pendingHtml = null;
212
258
  this._pendingSeq = null;
213
259
  this._pendingIdentityMap = null;
260
+ this._pendingExternal = null;
261
+ clearTimeout(this._holdRetryPeer);
262
+ clearTimeout(this._holdRetryExt);
263
+ this._holdRetryPeer = null;
264
+ this._holdRetryExt = null;
214
265
  }
215
266
 
216
267
  _mintId() {
@@ -295,21 +346,23 @@ class LiveSync {
295
346
  * the same logical elements, breaking convergence for newly-added
296
347
  * ambiguous siblings — exactly the case identity-map exists to fix.
297
348
  *
298
- * Walks live and parsed in lockstep using the same path scheme as
299
- * _buildIdentityMap. Filters [snapshot-remove] from the live side to
300
- * stay aligned with the sender's clone view. Aborts a subtree on
301
- * child-count divergence (e.g. local save-ignore additions) — those
302
- * elements fall through to content scoring on the next round, which
303
- * is the same fallback as a sender-side lockstep skip.
349
+ * Walks live and parsed in lockstep, reading ids off the parsed NODES
350
+ * (parsedWeakMap) rather than re-deriving dot-paths: a protected splice
351
+ * shifts paths, but the WeakMap entries ride the nodes and stay correct.
352
+ * Filters [snapshot-remove] from the live side to stay aligned with the
353
+ * sender's clone view. Aborts a subtree on child-count divergence (e.g.
354
+ * local save-ignore additions) — those elements fall through to content
355
+ * scoring on the next round, which is the same fallback as a sender-side
356
+ * lockstep skip.
304
357
  *
305
358
  * @param {Element} liveRoot - post-morph live tree root
306
- * @param {Element} parsedRoot - parsed-tree root (still has identityMap WeakMap entries)
307
- * @param {Object} identityMap - path → id map from the SSE payload
359
+ * @param {Element} parsedRoot - parsed-tree root
360
+ * @param {WeakMap} parsedWeakMap - parsed node → synthetic id
308
361
  */
309
- _fillInIdsAfterMorph(liveRoot, parsedRoot, identityMap) {
310
- if (!liveRoot || !parsedRoot || !identityMap) return;
311
- const visit = (live, parsed, path) => {
312
- const id = identityMap[path];
362
+ _fillInIdsAfterMorph(liveRoot, parsedRoot, parsedWeakMap) {
363
+ if (!liveRoot || !parsedRoot || !parsedWeakMap) return;
364
+ const visit = (live, parsed) => {
365
+ const id = parsedWeakMap.get(parsed);
313
366
  if (id && !this.liveWeakMap.has(live)) {
314
367
  this.liveWeakMap.set(live, id);
315
368
  }
@@ -320,10 +373,10 @@ class LiveSync {
320
373
  const parsedKids = parsed.children;
321
374
  if (liveKids.length !== parsedKids.length) return;
322
375
  for (let i = 0; i < liveKids.length; i++) {
323
- visit(liveKids[i], parsedKids[i], path === '' ? String(i) : `${path}.${i}`);
376
+ visit(liveKids[i], parsedKids[i]);
324
377
  }
325
378
  };
326
- visit(liveRoot, parsedRoot, '');
379
+ visit(liveRoot, parsedRoot);
327
380
  }
328
381
 
329
382
  /**
@@ -370,6 +423,34 @@ class LiveSync {
370
423
  if (this.onConnect) this.onConnect();
371
424
  };
372
425
 
426
+ // The cursor frame is a NAMED SSE event, so it never reaches onmessage and
427
+ // never looks like data. Its `resync` flag says the server could not retain
428
+ // everything between where this client resumed and the baseline it is
429
+ // sending: what this page holds is stale in a way no replay will fix.
430
+ //
431
+ // The repair is the token-free fetch of the served document this class
432
+ // already runs for a change too large to send, so noticing the flag is the
433
+ // whole of the work.
434
+ this.sse.addEventListener('cursor', (event) => {
435
+ let data;
436
+ try {
437
+ data = JSON.parse(event.data);
438
+ } catch {
439
+ return;
440
+ }
441
+ if (!data || data.resync !== true) return;
442
+ console.log('[LiveSync] Server could not replay everything; refetching the document');
443
+ // _fetchServedDocument, deliberately, and not _fetchExternalChange: that
444
+ // one drops a fetch whose seq is at or below the external watermark, and
445
+ // the cursor baseline routinely is, since it is the server's high-water
446
+ // mark and our own last applied change may already have reached it. A
447
+ // resync that skipped itself for being "already seen" would leave the page
448
+ // permanently stale, which is the exact failure the flag exists to report.
449
+ this._fetchServedDocument(typeof data.seq === 'number' ? data.seq : undefined, {
450
+ repair: true,
451
+ });
452
+ });
453
+
373
454
  this.sse.onmessage = (event) => {
374
455
  const data = JSON.parse(event.data);
375
456
 
@@ -393,8 +474,11 @@ class LiveSync {
393
474
  this.lastSeenSeq = seq;
394
475
  }
395
476
 
396
- // Handle notifications (show toast, don't morph)
477
+ // External disk changes ride the notification channel as first-class
478
+ // content (data.kind === 'external-change'). They apply silently —
479
+ // Google-Docs-style — so a handled one never reaches the toast branch.
397
480
  if (data.type === "notification") {
481
+ if (this._maybeAcceptExternalChange(data)) return;
398
482
  this.handleNotification(data);
399
483
  return;
400
484
  }
@@ -499,6 +583,7 @@ class LiveSync {
499
583
  this._log(`Sending update (HTML length: ${html.length}, lastHtml length: ${this.lastHtml?.length || 0})`);
500
584
 
501
585
  this._sendInFlight = true;
586
+ const gen = this._applyGen;
502
587
 
503
588
  // Absolute against the real origin, so a <base href> in the page cannot
504
589
  // redirect the whole document to an origin the author picked.
@@ -512,7 +597,16 @@ class LiveSync {
512
597
  })
513
598
  }).then(response => {
514
599
  if (response.ok) {
515
- this.lastHtml = html;
600
+ // A frame that applied while this POST was on the wire has already
601
+ // advanced lastHtml past this snapshot; assigning would rewind the
602
+ // diff base to pre-frame state and misclassify the frame's content
603
+ // as local edits on the next dirty apply.
604
+ if (this._applyGen === gen) {
605
+ this.lastHtml = html;
606
+ this._lastIdentityMap = identityMap || null;
607
+ } else {
608
+ this._log('Skipping lastHtml advance: a frame applied during the POST');
609
+ }
516
610
  } else {
517
611
  console.warn('[LiveSync] Save returned status:', response.status);
518
612
  }
@@ -527,6 +621,107 @@ class LiveSync {
527
621
  });
528
622
  }
529
623
 
624
+ /**
625
+ * Route an external disk change (htmlclay watcher) out of the notification
626
+ * path. Returns true when the notification was consumed — the caller must
627
+ * then skip the toast branch entirely (silent UX).
628
+ *
629
+ * New servers mark these with data.kind === 'external-change' and embed the
630
+ * disk HTML (omitted only when it exceeds the server's size cap). Old
631
+ * servers send a bare warning with the watcher's fixed message shape; both
632
+ * content-less forms fall back to a token-free fetch of the served page.
633
+ */
634
+ _maybeAcceptExternalChange(data) {
635
+ if (this.lane !== 'live') return false;
636
+ const info = data.data;
637
+ if (info && info.kind === 'external-change') {
638
+ if (typeof info.html === 'string') {
639
+ this._enqueueExternal(info.html, data.seq);
640
+ } else {
641
+ this._fetchExternalChange(data.seq);
642
+ }
643
+ return true;
644
+ }
645
+ if (typeof data.msg === 'string' && data.msg.endsWith('changed on disk outside this tab')) {
646
+ this._fetchExternalChange(data.seq);
647
+ return true;
648
+ }
649
+ return false;
650
+ }
651
+
652
+ _enqueueExternal(html, seq) {
653
+ if (typeof seq === 'number') {
654
+ if (seq <= this._lastExternalSeq) {
655
+ this._log(`Dropping replayed external change: seq=${seq}`);
656
+ return;
657
+ }
658
+ this._lastExternalSeq = seq;
659
+ }
660
+ this._pendingExternal = { html, seq, saveEpoch: this._saveEpoch };
661
+ this._scheduleNextFrame();
662
+ }
663
+
664
+ /**
665
+ * Content-less fallback: fetch the served document. The request carries no
666
+ * Sec-Fetch-Dest: document, so htmlclay serves it token-free; the morph's
667
+ * root-attribute veto keeps this tab's own token either way. The result is
668
+ * stamped with the triggering notification's seq and dropped if a newer
669
+ * external change (or our own save) landed while it was in flight.
670
+ */
671
+ _fetchExternalChange(seq) {
672
+ if (typeof seq === 'number') {
673
+ if (seq <= this._lastExternalSeq) return;
674
+ this._lastExternalSeq = seq;
675
+ }
676
+ this._fetchServedDocument(seq);
677
+ }
678
+
679
+ /**
680
+ * GET the served document and queue it as an external change. No watermark
681
+ * check of its own — callers own that — so it can also re-materialize a
682
+ * frame the epoch check refused (the fetched body is whatever disk holds
683
+ * NOW, which is always safe to apply).
684
+ */
685
+ _fetchServedDocument(seq, { attempt = 0, repair = false } = {}) {
686
+ const epoch = this._saveEpoch;
687
+ fetch(new URL(window.location.href), { cache: 'no-store' })
688
+ .then((response) => (response.ok ? response.text() : null))
689
+ .then((html) => {
690
+ if (this.isDestroyed || html == null) return;
691
+ if (typeof seq === 'number' && seq < this._lastExternalSeq) {
692
+ // A newer external change superseded this one, and its own fetch will
693
+ // queue a body. Except for a repair: that one exists because the server
694
+ // said replay cannot fix this page, so if the newer fetch fails there is
695
+ // nothing else coming. Refetch rather than drop the only repair.
696
+ if (repair && attempt < 3) {
697
+ this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
698
+ }
699
+ return;
700
+ }
701
+ if (this._saveEpoch > epoch) {
702
+ // An own save landed while the GET was in flight, so this body may
703
+ // predate it. Save-response order proves nothing about disk-write
704
+ // order — refetch for the newest bytes instead of dropping.
705
+ if (attempt < 3) {
706
+ console.log('[LiveSync] Refetching external change: own save landed mid-fetch');
707
+ this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
708
+ }
709
+ return;
710
+ }
711
+ this._pendingExternal = { html, seq, saveEpoch: epoch };
712
+ this._scheduleNextFrame();
713
+ })
714
+ .catch((err) => {
715
+ this._log('External-change fetch failed', err);
716
+ // The watermark already advanced for this seq; leaving it there
717
+ // would drop the change forever. Roll back so a replay or a later
718
+ // duplicate can redeliver it.
719
+ if (typeof seq === 'number' && this._lastExternalSeq === seq) {
720
+ this._lastExternalSeq = seq - 1;
721
+ }
722
+ });
723
+ }
724
+
530
725
  /**
531
726
  * Apply an update received from the server. Morphs the entire document.
532
727
  *
@@ -562,34 +757,79 @@ class LiveSync {
562
757
  }
563
758
 
564
759
  /**
565
- * Drain the pending slot once. Errors are caught and logged so a single
566
- * failed morph does not stop the queue.
760
+ * Drain one pending payload per frame. Errors are caught and logged so a
761
+ * single failed morph does not stop the queue.
762
+ *
763
+ * Two slots feed this: peer frames and disk-sourced external changes. When
764
+ * both are pending, the lower seq applies first so the two lanes land in
765
+ * server order; the other stays queued for the next frame.
567
766
  */
568
767
  async _runPending() {
569
768
  this._rafHandle = null;
570
769
  if (this.isDestroyed) return;
571
770
 
572
- const html = this._pendingHtml;
573
- const seq = this._pendingSeq;
574
- const identityMap = this._pendingIdentityMap;
575
- this._pendingHtml = null;
576
- this._pendingSeq = null;
577
- this._pendingIdentityMap = null;
578
- if (html == null) return;
771
+ const ext = this._pendingExternal;
772
+ let runExternal = false;
773
+ if (ext != null) {
774
+ if (this._pendingHtml == null) {
775
+ runExternal = true;
776
+ } else {
777
+ runExternal = !(
778
+ typeof ext.seq === 'number' &&
779
+ typeof this._pendingSeq === 'number' &&
780
+ this._pendingSeq < ext.seq
781
+ );
782
+ }
783
+ }
579
784
 
580
- this._morphInFlight = true;
581
- try {
582
- await this._doApplyUpdate(html, seq, identityMap);
583
- } catch (err) {
584
- console.error('[LiveSync] applyUpdate failed:', err);
585
- } finally {
586
- this._morphInFlight = false;
785
+ if (runExternal) {
786
+ this._pendingExternal = null;
787
+ // Stale-at-drain checks: a newer external change already superseded
788
+ // this frame, or our own save landed after it was queued.
789
+ if (typeof ext.seq === 'number' && ext.seq < this._lastExternalSeq) {
790
+ this._log('Dropping superseded external change at drain');
791
+ } else if (ext.saveEpoch !== this._saveEpoch) {
792
+ // The epoch moved, but save-response order does not prove disk-write
793
+ // order: the frame's content may still be newer than our save.
794
+ // Refetch the served document — applying what disk holds NOW is
795
+ // always safe — instead of dropping the frame.
796
+ console.log('[LiveSync] Refetching external change: own save landed after queue');
797
+ this._fetchServedDocument(ext.seq);
798
+ } else {
799
+ this._morphInFlight = true;
800
+ try {
801
+ await this._doApplyExternal(ext.html, ext.seq);
802
+ } catch (err) {
803
+ console.error('[LiveSync] applyExternal failed:', err);
804
+ } finally {
805
+ this._morphInFlight = false;
806
+ }
807
+ }
808
+ } else {
809
+ const html = this._pendingHtml;
810
+ const seq = this._pendingSeq;
811
+ const identityMap = this._pendingIdentityMap;
812
+ this._pendingHtml = null;
813
+ this._pendingSeq = null;
814
+ this._pendingIdentityMap = null;
815
+ if (html == null) return;
816
+
817
+ this._morphInFlight = true;
818
+ try {
819
+ await this._doApplyUpdate(html, seq, identityMap);
820
+ } catch (err) {
821
+ console.error('[LiveSync] applyUpdate failed:', err);
822
+ } finally {
823
+ this._morphInFlight = false;
824
+ }
587
825
  }
588
826
 
589
- // A newer payload may have arrived during the morph. Schedule another
590
- // frame to drain it. Without this, late-arriving updates would sit
591
- // forever until the next applyUpdate call.
592
- if (!this.isDestroyed && this._pendingHtml != null) {
827
+ // A newer payload may have arrived during the morph, or the other slot
828
+ // is still holding one. Schedule another frame to drain it. Without
829
+ // this, late-arriving updates would sit forever until the next
830
+ // applyUpdate call.
831
+ if (!this.isDestroyed &&
832
+ (this._pendingHtml != null || this._pendingExternal != null)) {
593
833
  this._scheduleNextFrame();
594
834
  }
595
835
  }
@@ -630,6 +870,7 @@ class LiveSync {
630
870
 
631
871
  // Pause mutation observer so morph doesn't trigger autosave
632
872
  Mutation.pause();
873
+ pauseGate();
633
874
 
634
875
  // Parse as full document
635
876
  const parser = new DOMParser();
@@ -666,7 +907,50 @@ class LiveSync {
666
907
  const beforeAttributeUpdated = (name, element) =>
667
908
  isTabLocalRootAttr(name, element) ? false : undefined;
668
909
 
910
+ let retainedRoots = 0;
669
911
  try {
912
+ // Scoped sync: when this tab might hold unsaved edits, splice them into
913
+ // the incoming document BEFORE the morph so it cannot clobber them.
914
+ // The clean path skips every capture and stays byte-identical to a
915
+ // plain full morph.
916
+ if (this.lane === 'live' && pageMaybeDirty()) {
917
+ const protection = protectPeerDoc({
918
+ newDoc,
919
+ parsedWeakMap,
920
+ baseHtml: this.lastHtml,
921
+ baseIdentityMap: this._lastIdentityMap,
922
+ liveWeakMap: this.liveWeakMap,
923
+ });
924
+ if (!protection.ok) {
925
+ // Hold the whole frame: a dirty section couldn't be safely merged
926
+ // (or no baseline exists yet). Nothing morphs and no baseline
927
+ // moves; the tab keeps its local state and converges through its
928
+ // own next save. Deliberately NO proactive save here — a hold can
929
+ // fire on a manual-save page, which must never auto-write. The
930
+ // retry re-queues the same frame so it still applies if the
931
+ // blocking edit is undone; the slot-empty check and the drain's
932
+ // staleness checks drop it once superseded.
933
+ console.log('[LiveSync] Holding incoming update: unsaved local section cannot be safely merged', protection.held?.el || '');
934
+ const epochAtHold = this._saveEpoch;
935
+ const seenAtHold = this.lastSeenSeq;
936
+ clearTimeout(this._holdRetryPeer);
937
+ this._holdRetryPeer = setTimeout(() => {
938
+ this._holdRetryPeer = null;
939
+ if (this.isDestroyed || this._pendingHtml != null) return;
940
+ // Only while the world hasn't moved: an own save or any newer
941
+ // frame since the hold makes this payload stale.
942
+ if (this._saveEpoch !== epochAtHold) return;
943
+ if (this.lastSeenSeq !== seenAtHold) return;
944
+ this._pendingHtml = html;
945
+ this._pendingSeq = seq;
946
+ this._pendingIdentityMap = identityMap;
947
+ this._scheduleNextFrame();
948
+ }, 3000);
949
+ return;
950
+ }
951
+ retainedRoots = protection.entries.length;
952
+ }
953
+
670
954
  // Morph entire document. We MUST await — HyperMorph.morph returns a
671
955
  // Promise when `scripts: { handle: true }` needs to wait for external
672
956
  // scripts to load. If we don't await, Mutation.resume() fires before
@@ -701,7 +985,7 @@ class LiveSync {
701
985
  // would mint fresh IDs on its next save for those elements,
702
986
  // breaking convergence exactly for newly-added ambiguous siblings.
703
987
  if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
704
- this._fillInIdsAfterMorph(document.documentElement, newDoc.documentElement, identityMap);
988
+ this._fillInIdsAfterMorph(document.documentElement, newDoc.documentElement, parsedWeakMap);
705
989
  }
706
990
 
707
991
  // Only mark lastHtml after a successful morph so that a failed apply
@@ -709,7 +993,31 @@ class LiveSync {
709
993
  // mistakenly skipped as "unchanged". Note: lastSeenSeq is advanced at
710
994
  // receive time (in onmessage) so the staleness check covers own-save
711
995
  // echoes even when they don't reach this point.
996
+ //
997
+ // lastHtml is the RAW incoming frame even after a protected apply. A
998
+ // patched serialization would poison the next frame's diff base (frame
999
+ // two of a burst would read the protected section as clean and clobber
1000
+ // it) and could dedupe away the convergence send. Convergence is driven
1001
+ // by the explicit save below instead.
712
1002
  this.lastHtml = html;
1003
+ this._lastIdentityMap =
1004
+ identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)
1005
+ ? identityMap
1006
+ : null;
1007
+ this._applyGen++;
1008
+
1009
+ // Cross-lane baseline: the DOM now holds this frame's content, but the
1010
+ // DISK baseline (lastSavedContents) still describes pre-frame state. A
1011
+ // later dirty disk apply diffing against that stale baseline would
1012
+ // classify everything this frame brought as unsaved local edits and
1013
+ // splice it over newer disk bytes. A verified-clean apply is the one
1014
+ // moment the DOM is truthfully "as if saved", so the comparison
1015
+ // baseline advances here too. Skipped whenever the gate reports dirty
1016
+ // (including typing that arrived during the morph's async wait, which
1017
+ // must never be recorded as saved).
1018
+ if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
1019
+ setLastSavedContents(captureForComparison({ flushUndo: false }));
1020
+ }
713
1021
 
714
1022
  // Announce that a remote morph just landed, so document-level listeners
715
1023
  // that are deaf to Mutation.pause (e.g. the hypercms form panel) can
@@ -718,17 +1026,159 @@ class LiveSync {
718
1026
  // onmessage before applyUpdate is ever called. Covers every SSE morph
719
1027
  // source (peer edit, version restore, body-swap) since they all funnel
720
1028
  // through this single choke point.
1029
+ //
1030
+ // `source` is what lets a listener tell the two apply paths apart. clay.wire
1031
+ // waits for a DISK frame to call an agent's write landed, and another tab's
1032
+ // edit arriving first would otherwise report the wrong bytes as delivered.
721
1033
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
722
- detail: { seq }
1034
+ detail: { seq, source: 'peer' }
723
1035
  }));
724
1036
  } finally {
725
1037
  this._log('applyUpdate - morph complete, resuming mutations');
726
1038
  Mutation.resume();
1039
+ resumeGate();
727
1040
  // Defer past microtask boundary — MutationObserver callbacks fire before
728
1041
  // this, so isPaused catches any stray snapshots from the morph itself.
729
1042
  await new Promise((resolve) => setTimeout(resolve, 0));
730
1043
  this.isPaused = false;
731
1044
  }
1045
+
1046
+ // Convergence: a protected apply produced a merged state (our sections +
1047
+ // their frame) that exists only in this DOM. Push it out explicitly — the
1048
+ // morph ran under Mutation.pause, so no autosave was triggered, and a
1049
+ // pending autosave debounce may already have fired mid-flight. Runs after
1050
+ // isPaused is lifted so the save's snapshot-ready relay reaches peers.
1051
+ if (retainedRoots > 0) {
1052
+ savePageThrottled();
1053
+ }
1054
+ }
1055
+
1056
+ /**
1057
+ * Apply an external disk change to this edit-mode tab. Same shape as
1058
+ * _doApplyUpdate with three differences: the incoming document is save
1059
+ * domain, so it is edit-mode ACTIVATED before the morph (inert attribute
1060
+ * forms flipped live, as boot does on page load); dirty protection diffs
1061
+ * against the save baseline instead of lastHtml; and on a clean apply the
1062
+ * save baseline advances to a post-morph local comparison capture — true by
1063
+ * construction, where any wire-derived baseline permanently mismatches
1064
+ * (token, doctype, transform and parse divergences). A dirty apply leaves
1065
+ * both baselines alone so the convergence save below sees its own changes
1066
+ * (and, via the save pipeline, refreshes both). A clean apply also rebuilds
1067
+ * lastHtml, because the peer lane's diff base must track what the DOM now
1068
+ * holds or a later dirty peer apply misclassifies this frame's content as
1069
+ * local edits.
1070
+ */
1071
+ async _doApplyExternal(html, seq) {
1072
+ this._log(`applyExternal - external disk change (seq=${seq})`);
1073
+ this.isPaused = true;
1074
+
1075
+ const scrollX = window.scrollX;
1076
+ const scrollY = window.scrollY;
1077
+
1078
+ Mutation.pause();
1079
+ pauseGate();
1080
+
1081
+ let retainedRoots = 0;
1082
+ try {
1083
+ const parser = new DOMParser();
1084
+ const newDoc = parser.parseFromString(html, 'text/html');
1085
+
1086
+ // Lane-guarded exactly like the peer path. A view-mode tab has no save
1087
+ // baseline — every writer of lastSavedContents is edit-gated — so
1088
+ // protectDiskDoc can only ever refuse, and the frame would hold, retry
1089
+ // every 3s, and hold again forever. The gate still reads dirty there,
1090
+ // because persistProbeDirty inspects the live DOM and a visitor can type
1091
+ // into a [persist] field. Before the resync repair this path was
1092
+ // unreachable outside the live lane; now it is the repair's own route.
1093
+ if (this.lane === 'live' && pageMaybeDirty()) {
1094
+ const protection = protectDiskDoc({ newDoc });
1095
+ if (!protection.ok) {
1096
+ // Hold: nothing morphs, no baseline moves, and deliberately NO
1097
+ // proactive save (a hold can fire on a manual-save page, which
1098
+ // must never auto-write). The frame's seq watermark has already
1099
+ // advanced, so nothing redelivers it on its own; the retry
1100
+ // re-queues it with the epoch captured NOW, so the drain's epoch
1101
+ // check turns an intervening own save into a refetch of current
1102
+ // disk instead of a stale re-apply, and the seq check drops it
1103
+ // once a newer external change supersedes it.
1104
+ console.log('[LiveSync] Holding external change: unsaved local section cannot be safely merged', protection.held?.el || '');
1105
+ const epochAtHold = this._saveEpoch;
1106
+ clearTimeout(this._holdRetryExt);
1107
+ this._holdRetryExt = setTimeout(() => {
1108
+ this._holdRetryExt = null;
1109
+ if (this.isDestroyed || this._pendingExternal != null) return;
1110
+ this._pendingExternal = { html, seq, saveEpoch: epochAtHold };
1111
+ this._scheduleNextFrame();
1112
+ }, 3000);
1113
+ return;
1114
+ }
1115
+ retainedRoots = protection.entries.length;
1116
+ }
1117
+
1118
+ activateIncomingDoc(newDoc.documentElement);
1119
+
1120
+ const liveWeakMap = this.liveWeakMap;
1121
+ const key = (el) =>
1122
+ liveWeakMap.get(el) ||
1123
+ (el.getAttribute && el.getAttribute('data-id')) ||
1124
+ (el.getAttribute && el.getAttribute('id')) ||
1125
+ null;
1126
+ const beforeAttributeUpdated = (name, element) =>
1127
+ isTabLocalRootAttr(name, element) ? false : undefined;
1128
+
1129
+ await HyperMorph.morph(document.documentElement, newDoc.documentElement, {
1130
+ morphStyle: 'outerHTML',
1131
+ ignoreActiveValue: true,
1132
+ head: { style: 'merge' },
1133
+ scripts: {
1134
+ handle: true,
1135
+ matchMode: 'smart',
1136
+ mergeBase: this.lastHtml,
1137
+ mergeTags: mergeTagRecognizers
1138
+ },
1139
+ key,
1140
+ callbacks: { beforeAttributeUpdated }
1141
+ });
1142
+
1143
+ window.scrollTo(scrollX, scrollY);
1144
+
1145
+ if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
1146
+ // Clean apply: the DOM now IS the disk state, so a local comparison
1147
+ // capture of it is the truthful baseline. The next no-op save skips,
1148
+ // beforeunload stays quiet. The dirty re-check matters: typing that
1149
+ // arrived during the morph's async wait would otherwise be captured
1150
+ // into the baseline and recorded as saved without reaching disk.
1151
+ setLastSavedContents(captureForComparison({ flushUndo: false }));
1152
+ setUnsavedChanges(false);
1153
+
1154
+ // Cross-lane baseline: the PEER diff base (lastHtml) would otherwise
1155
+ // still describe pre-frame state, and a later dirty peer apply would
1156
+ // classify this frame's content as local edits and splice it over a
1157
+ // newer peer frame. Rebuild it exactly the way the send pipeline
1158
+ // does, so it stays in the snapshot domain.
1159
+ const clone = captureSnapshot({ flushUndo: false });
1160
+ this.lastHtml = serializeForSync(clone);
1161
+ this._lastIdentityMap = this._buildIdentityMap(document.documentElement, clone);
1162
+ this._applyGen++;
1163
+ }
1164
+
1165
+ document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1166
+ detail: { seq, source: 'disk' }
1167
+ }));
1168
+ } finally {
1169
+ this._log('applyExternal - morph complete, resuming mutations');
1170
+ Mutation.resume();
1171
+ resumeGate();
1172
+ await new Promise((resolve) => setTimeout(resolve, 0));
1173
+ this.isPaused = false;
1174
+ }
1175
+
1176
+ // Convergence: disk holds the writer's version, this DOM holds the merge.
1177
+ // The baseline was left pre-external, so the save sees both our retained
1178
+ // sections and the external content as changes and writes the merge back.
1179
+ if (retainedRoots > 0) {
1180
+ savePageThrottled();
1181
+ }
732
1182
  }
733
1183
 
734
1184
  /**