@panphora/clayjs 0.4.2 → 0.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.
@@ -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
  /**
@@ -393,8 +446,11 @@ class LiveSync {
393
446
  this.lastSeenSeq = seq;
394
447
  }
395
448
 
396
- // Handle notifications (show toast, don't morph)
449
+ // External disk changes ride the notification channel as first-class
450
+ // content (data.kind === 'external-change'). They apply silently —
451
+ // Google-Docs-style — so a handled one never reaches the toast branch.
397
452
  if (data.type === "notification") {
453
+ if (this._maybeAcceptExternalChange(data)) return;
398
454
  this.handleNotification(data);
399
455
  return;
400
456
  }
@@ -499,6 +555,7 @@ class LiveSync {
499
555
  this._log(`Sending update (HTML length: ${html.length}, lastHtml length: ${this.lastHtml?.length || 0})`);
500
556
 
501
557
  this._sendInFlight = true;
558
+ const gen = this._applyGen;
502
559
 
503
560
  // Absolute against the real origin, so a <base href> in the page cannot
504
561
  // redirect the whole document to an origin the author picked.
@@ -512,7 +569,16 @@ class LiveSync {
512
569
  })
513
570
  }).then(response => {
514
571
  if (response.ok) {
515
- this.lastHtml = html;
572
+ // A frame that applied while this POST was on the wire has already
573
+ // advanced lastHtml past this snapshot; assigning would rewind the
574
+ // diff base to pre-frame state and misclassify the frame's content
575
+ // as local edits on the next dirty apply.
576
+ if (this._applyGen === gen) {
577
+ this.lastHtml = html;
578
+ this._lastIdentityMap = identityMap || null;
579
+ } else {
580
+ this._log('Skipping lastHtml advance: a frame applied during the POST');
581
+ }
516
582
  } else {
517
583
  console.warn('[LiveSync] Save returned status:', response.status);
518
584
  }
@@ -527,6 +593,98 @@ class LiveSync {
527
593
  });
528
594
  }
529
595
 
596
+ /**
597
+ * Route an external disk change (htmlclay watcher) out of the notification
598
+ * path. Returns true when the notification was consumed — the caller must
599
+ * then skip the toast branch entirely (silent UX).
600
+ *
601
+ * New servers mark these with data.kind === 'external-change' and embed the
602
+ * disk HTML (omitted only when it exceeds the server's size cap). Old
603
+ * servers send a bare warning with the watcher's fixed message shape; both
604
+ * content-less forms fall back to a token-free fetch of the served page.
605
+ */
606
+ _maybeAcceptExternalChange(data) {
607
+ if (this.lane !== 'live') return false;
608
+ const info = data.data;
609
+ if (info && info.kind === 'external-change') {
610
+ if (typeof info.html === 'string') {
611
+ this._enqueueExternal(info.html, data.seq);
612
+ } else {
613
+ this._fetchExternalChange(data.seq);
614
+ }
615
+ return true;
616
+ }
617
+ if (typeof data.msg === 'string' && data.msg.endsWith('changed on disk outside this tab')) {
618
+ this._fetchExternalChange(data.seq);
619
+ return true;
620
+ }
621
+ return false;
622
+ }
623
+
624
+ _enqueueExternal(html, seq) {
625
+ if (typeof seq === 'number') {
626
+ if (seq <= this._lastExternalSeq) {
627
+ this._log(`Dropping replayed external change: seq=${seq}`);
628
+ return;
629
+ }
630
+ this._lastExternalSeq = seq;
631
+ }
632
+ this._pendingExternal = { html, seq, saveEpoch: this._saveEpoch };
633
+ this._scheduleNextFrame();
634
+ }
635
+
636
+ /**
637
+ * Content-less fallback: fetch the served document. The request carries no
638
+ * Sec-Fetch-Dest: document, so htmlclay serves it token-free; the morph's
639
+ * root-attribute veto keeps this tab's own token either way. The result is
640
+ * stamped with the triggering notification's seq and dropped if a newer
641
+ * external change (or our own save) landed while it was in flight.
642
+ */
643
+ _fetchExternalChange(seq) {
644
+ if (typeof seq === 'number') {
645
+ if (seq <= this._lastExternalSeq) return;
646
+ this._lastExternalSeq = seq;
647
+ }
648
+ this._fetchServedDocument(seq);
649
+ }
650
+
651
+ /**
652
+ * GET the served document and queue it as an external change. No watermark
653
+ * check of its own — callers own that — so it can also re-materialize a
654
+ * frame the epoch check refused (the fetched body is whatever disk holds
655
+ * NOW, which is always safe to apply).
656
+ */
657
+ _fetchServedDocument(seq, attempt = 0) {
658
+ const epoch = this._saveEpoch;
659
+ fetch(new URL(window.location.href), { cache: 'no-store' })
660
+ .then((response) => (response.ok ? response.text() : null))
661
+ .then((html) => {
662
+ if (this.isDestroyed || html == null) return;
663
+ if (typeof seq === 'number' && seq < this._lastExternalSeq) return;
664
+ if (this._saveEpoch > epoch) {
665
+ // An own save landed while the GET was in flight, so this body may
666
+ // predate it. Save-response order proves nothing about disk-write
667
+ // order — refetch for the newest bytes instead of dropping.
668
+ if (attempt < 3) {
669
+ console.log('[LiveSync] Refetching external change: own save landed mid-fetch');
670
+ this._fetchServedDocument(seq, attempt + 1);
671
+ }
672
+ return;
673
+ }
674
+ this._pendingExternal = { html, seq, saveEpoch: epoch };
675
+ this._scheduleNextFrame();
676
+ })
677
+ .catch((err) => {
678
+ this._log('External-change fetch failed', err);
679
+ // The watermark already advanced for this seq; leaving it there
680
+ // would drop the change forever. Roll back so a replay or a later
681
+ // duplicate can redeliver it.
682
+ if (typeof seq === 'number' && this._lastExternalSeq === seq) {
683
+ this._lastExternalSeq = seq - 1;
684
+ }
685
+ });
686
+ }
687
+
530
688
  /**
531
689
  * Apply an update received from the server. Morphs the entire document.
532
690
  *
@@ -562,34 +720,79 @@ class LiveSync {
562
720
  }
563
721
 
564
722
  /**
565
- * Drain the pending slot once. Errors are caught and logged so a single
566
- * failed morph does not stop the queue.
723
+ * Drain one pending payload per frame. Errors are caught and logged so a
724
+ * single failed morph does not stop the queue.
725
+ *
726
+ * Two slots feed this: peer frames and disk-sourced external changes. When
727
+ * both are pending, the lower seq applies first so the two lanes land in
728
+ * server order; the other stays queued for the next frame.
567
729
  */
568
730
  async _runPending() {
569
731
  this._rafHandle = null;
570
732
  if (this.isDestroyed) return;
571
733
 
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;
734
+ const ext = this._pendingExternal;
735
+ let runExternal = false;
736
+ if (ext != null) {
737
+ if (this._pendingHtml == null) {
738
+ runExternal = true;
739
+ } else {
740
+ runExternal = !(
741
+ typeof ext.seq === 'number' &&
742
+ typeof this._pendingSeq === 'number' &&
743
+ this._pendingSeq < ext.seq
744
+ );
745
+ }
746
+ }
579
747
 
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;
748
+ if (runExternal) {
749
+ this._pendingExternal = null;
750
+ // Stale-at-drain checks: a newer external change already superseded
751
+ // this frame, or our own save landed after it was queued.
752
+ if (typeof ext.seq === 'number' && ext.seq < this._lastExternalSeq) {
753
+ this._log('Dropping superseded external change at drain');
754
+ } else if (ext.saveEpoch !== this._saveEpoch) {
755
+ // The epoch moved, but save-response order does not prove disk-write
756
+ // order: the frame's content may still be newer than our save.
757
+ // Refetch the served document — applying what disk holds NOW is
758
+ // always safe — instead of dropping the frame.
759
+ console.log('[LiveSync] Refetching external change: own save landed after queue');
760
+ this._fetchServedDocument(ext.seq);
761
+ } else {
762
+ this._morphInFlight = true;
763
+ try {
764
+ await this._doApplyExternal(ext.html, ext.seq);
765
+ } catch (err) {
766
+ console.error('[LiveSync] applyExternal failed:', err);
767
+ } finally {
768
+ this._morphInFlight = false;
769
+ }
770
+ }
771
+ } else {
772
+ const html = this._pendingHtml;
773
+ const seq = this._pendingSeq;
774
+ const identityMap = this._pendingIdentityMap;
775
+ this._pendingHtml = null;
776
+ this._pendingSeq = null;
777
+ this._pendingIdentityMap = null;
778
+ if (html == null) return;
779
+
780
+ this._morphInFlight = true;
781
+ try {
782
+ await this._doApplyUpdate(html, seq, identityMap);
783
+ } catch (err) {
784
+ console.error('[LiveSync] applyUpdate failed:', err);
785
+ } finally {
786
+ this._morphInFlight = false;
787
+ }
587
788
  }
588
789
 
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) {
790
+ // A newer payload may have arrived during the morph, or the other slot
791
+ // is still holding one. Schedule another frame to drain it. Without
792
+ // this, late-arriving updates would sit forever until the next
793
+ // applyUpdate call.
794
+ if (!this.isDestroyed &&
795
+ (this._pendingHtml != null || this._pendingExternal != null)) {
593
796
  this._scheduleNextFrame();
594
797
  }
595
798
  }
@@ -630,6 +833,7 @@ class LiveSync {
630
833
 
631
834
  // Pause mutation observer so morph doesn't trigger autosave
632
835
  Mutation.pause();
836
+ pauseGate();
633
837
 
634
838
  // Parse as full document
635
839
  const parser = new DOMParser();
@@ -666,7 +870,50 @@ class LiveSync {
666
870
  const beforeAttributeUpdated = (name, element) =>
667
871
  isTabLocalRootAttr(name, element) ? false : undefined;
668
872
 
873
+ let retainedRoots = 0;
669
874
  try {
875
+ // Scoped sync: when this tab might hold unsaved edits, splice them into
876
+ // the incoming document BEFORE the morph so it cannot clobber them.
877
+ // The clean path skips every capture and stays byte-identical to a
878
+ // plain full morph.
879
+ if (this.lane === 'live' && pageMaybeDirty()) {
880
+ const protection = protectPeerDoc({
881
+ newDoc,
882
+ parsedWeakMap,
883
+ baseHtml: this.lastHtml,
884
+ baseIdentityMap: this._lastIdentityMap,
885
+ liveWeakMap: this.liveWeakMap,
886
+ });
887
+ if (!protection.ok) {
888
+ // Hold the whole frame: a dirty section couldn't be safely merged
889
+ // (or no baseline exists yet). Nothing morphs and no baseline
890
+ // moves; the tab keeps its local state and converges through its
891
+ // own next save. Deliberately NO proactive save here — a hold can
892
+ // fire on a manual-save page, which must never auto-write. The
893
+ // retry re-queues the same frame so it still applies if the
894
+ // blocking edit is undone; the slot-empty check and the drain's
895
+ // staleness checks drop it once superseded.
896
+ console.log('[LiveSync] Holding incoming update: unsaved local section cannot be safely merged', protection.held?.el || '');
897
+ const epochAtHold = this._saveEpoch;
898
+ const seenAtHold = this.lastSeenSeq;
899
+ clearTimeout(this._holdRetryPeer);
900
+ this._holdRetryPeer = setTimeout(() => {
901
+ this._holdRetryPeer = null;
902
+ if (this.isDestroyed || this._pendingHtml != null) return;
903
+ // Only while the world hasn't moved: an own save or any newer
904
+ // frame since the hold makes this payload stale.
905
+ if (this._saveEpoch !== epochAtHold) return;
906
+ if (this.lastSeenSeq !== seenAtHold) return;
907
+ this._pendingHtml = html;
908
+ this._pendingSeq = seq;
909
+ this._pendingIdentityMap = identityMap;
910
+ this._scheduleNextFrame();
911
+ }, 3000);
912
+ return;
913
+ }
914
+ retainedRoots = protection.entries.length;
915
+ }
916
+
670
917
  // Morph entire document. We MUST await — HyperMorph.morph returns a
671
918
  // Promise when `scripts: { handle: true }` needs to wait for external
672
919
  // scripts to load. If we don't await, Mutation.resume() fires before
@@ -701,7 +948,7 @@ class LiveSync {
701
948
  // would mint fresh IDs on its next save for those elements,
702
949
  // breaking convergence exactly for newly-added ambiguous siblings.
703
950
  if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
704
- this._fillInIdsAfterMorph(document.documentElement, newDoc.documentElement, identityMap);
951
+ this._fillInIdsAfterMorph(document.documentElement, newDoc.documentElement, parsedWeakMap);
705
952
  }
706
953
 
707
954
  // Only mark lastHtml after a successful morph so that a failed apply
@@ -709,7 +956,31 @@ class LiveSync {
709
956
  // mistakenly skipped as "unchanged". Note: lastSeenSeq is advanced at
710
957
  // receive time (in onmessage) so the staleness check covers own-save
711
958
  // echoes even when they don't reach this point.
959
+ //
960
+ // lastHtml is the RAW incoming frame even after a protected apply. A
961
+ // patched serialization would poison the next frame's diff base (frame
962
+ // two of a burst would read the protected section as clean and clobber
963
+ // it) and could dedupe away the convergence send. Convergence is driven
964
+ // by the explicit save below instead.
712
965
  this.lastHtml = html;
966
+ this._lastIdentityMap =
967
+ identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)
968
+ ? identityMap
969
+ : null;
970
+ this._applyGen++;
971
+
972
+ // Cross-lane baseline: the DOM now holds this frame's content, but the
973
+ // DISK baseline (lastSavedContents) still describes pre-frame state. A
974
+ // later dirty disk apply diffing against that stale baseline would
975
+ // classify everything this frame brought as unsaved local edits and
976
+ // splice it over newer disk bytes. A verified-clean apply is the one
977
+ // moment the DOM is truthfully "as if saved", so the comparison
978
+ // baseline advances here too. Skipped whenever the gate reports dirty
979
+ // (including typing that arrived during the morph's async wait, which
980
+ // must never be recorded as saved).
981
+ if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
982
+ setLastSavedContents(captureForComparison({ flushUndo: false }));
983
+ }
713
984
 
714
985
  // Announce that a remote morph just landed, so document-level listeners
715
986
  // that are deaf to Mutation.pause (e.g. the hypercms form panel) can
@@ -724,11 +995,142 @@ class LiveSync {
724
995
  } finally {
725
996
  this._log('applyUpdate - morph complete, resuming mutations');
726
997
  Mutation.resume();
998
+ resumeGate();
727
999
  // Defer past microtask boundary — MutationObserver callbacks fire before
728
1000
  // this, so isPaused catches any stray snapshots from the morph itself.
729
1001
  await new Promise((resolve) => setTimeout(resolve, 0));
730
1002
  this.isPaused = false;
731
1003
  }
1004
+
1005
+ // Convergence: a protected apply produced a merged state (our sections +
1006
+ // their frame) that exists only in this DOM. Push it out explicitly — the
1007
+ // morph ran under Mutation.pause, so no autosave was triggered, and a
1008
+ // pending autosave debounce may already have fired mid-flight. Runs after
1009
+ // isPaused is lifted so the save's snapshot-ready relay reaches peers.
1010
+ if (retainedRoots > 0) {
1011
+ savePageThrottled();
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * Apply an external disk change to this edit-mode tab. Same shape as
1017
+ * _doApplyUpdate with three differences: the incoming document is save
1018
+ * domain, so it is edit-mode ACTIVATED before the morph (inert attribute
1019
+ * forms flipped live, as boot does on page load); dirty protection diffs
1020
+ * against the save baseline instead of lastHtml; and on a clean apply the
1021
+ * save baseline advances to a post-morph local comparison capture — true by
1022
+ * construction, where any wire-derived baseline permanently mismatches
1023
+ * (token, doctype, transform and parse divergences). A dirty apply leaves
1024
+ * both baselines alone so the convergence save below sees its own changes
1025
+ * (and, via the save pipeline, refreshes both). A clean apply also rebuilds
1026
+ * lastHtml, because the peer lane's diff base must track what the DOM now
1027
+ * holds or a later dirty peer apply misclassifies this frame's content as
1028
+ * local edits.
1029
+ */
1030
+ async _doApplyExternal(html, seq) {
1031
+ this._log(`applyExternal - external disk change (seq=${seq})`);
1032
+ this.isPaused = true;
1033
+
1034
+ const scrollX = window.scrollX;
1035
+ const scrollY = window.scrollY;
1036
+
1037
+ Mutation.pause();
1038
+ pauseGate();
1039
+
1040
+ let retainedRoots = 0;
1041
+ try {
1042
+ const parser = new DOMParser();
1043
+ const newDoc = parser.parseFromString(html, 'text/html');
1044
+
1045
+ if (pageMaybeDirty()) {
1046
+ const protection = protectDiskDoc({ newDoc });
1047
+ if (!protection.ok) {
1048
+ // Hold: nothing morphs, no baseline moves, and deliberately NO
1049
+ // proactive save (a hold can fire on a manual-save page, which
1050
+ // must never auto-write). The frame's seq watermark has already
1051
+ // advanced, so nothing redelivers it on its own; the retry
1052
+ // re-queues it with the epoch captured NOW, so the drain's epoch
1053
+ // check turns an intervening own save into a refetch of current
1054
+ // disk instead of a stale re-apply, and the seq check drops it
1055
+ // once a newer external change supersedes it.
1056
+ console.log('[LiveSync] Holding external change: unsaved local section cannot be safely merged', protection.held?.el || '');
1057
+ const epochAtHold = this._saveEpoch;
1058
+ clearTimeout(this._holdRetryExt);
1059
+ this._holdRetryExt = setTimeout(() => {
1060
+ this._holdRetryExt = null;
1061
+ if (this.isDestroyed || this._pendingExternal != null) return;
1062
+ this._pendingExternal = { html, seq, saveEpoch: epochAtHold };
1063
+ this._scheduleNextFrame();
1064
+ }, 3000);
1065
+ return;
1066
+ }
1067
+ retainedRoots = protection.entries.length;
1068
+ }
1069
+
1070
+ activateIncomingDoc(newDoc.documentElement);
1071
+
1072
+ const liveWeakMap = this.liveWeakMap;
1073
+ const key = (el) =>
1074
+ liveWeakMap.get(el) ||
1075
+ (el.getAttribute && el.getAttribute('data-id')) ||
1076
+ (el.getAttribute && el.getAttribute('id')) ||
1077
+ null;
1078
+ const beforeAttributeUpdated = (name, element) =>
1079
+ isTabLocalRootAttr(name, element) ? false : undefined;
1080
+
1081
+ await HyperMorph.morph(document.documentElement, newDoc.documentElement, {
1082
+ morphStyle: 'outerHTML',
1083
+ ignoreActiveValue: true,
1084
+ head: { style: 'merge' },
1085
+ scripts: {
1086
+ handle: true,
1087
+ matchMode: 'smart',
1088
+ mergeBase: this.lastHtml,
1089
+ mergeTags: mergeTagRecognizers
1090
+ },
1091
+ key,
1092
+ callbacks: { beforeAttributeUpdated }
1093
+ });
1094
+
1095
+ window.scrollTo(scrollX, scrollY);
1096
+
1097
+ if (retainedRoots === 0 && !pageMaybeDirty()) {
1098
+ // Clean apply: the DOM now IS the disk state, so a local comparison
1099
+ // capture of it is the truthful baseline. The next no-op save skips,
1100
+ // beforeunload stays quiet. The dirty re-check matters: typing that
1101
+ // arrived during the morph's async wait would otherwise be captured
1102
+ // into the baseline and recorded as saved without reaching disk.
1103
+ setLastSavedContents(captureForComparison({ flushUndo: false }));
1104
+ setUnsavedChanges(false);
1105
+
1106
+ // Cross-lane baseline: the PEER diff base (lastHtml) would otherwise
1107
+ // still describe pre-frame state, and a later dirty peer apply would
1108
+ // classify this frame's content as local edits and splice it over a
1109
+ // newer peer frame. Rebuild it exactly the way the send pipeline
1110
+ // does, so it stays in the snapshot domain.
1111
+ const clone = captureSnapshot({ flushUndo: false });
1112
+ this.lastHtml = serializeForSync(clone);
1113
+ this._lastIdentityMap = this._buildIdentityMap(document.documentElement, clone);
1114
+ this._applyGen++;
1115
+ }
1116
+
1117
+ document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1118
+ detail: { seq }
1119
+ }));
1120
+ } finally {
1121
+ this._log('applyExternal - morph complete, resuming mutations');
1122
+ Mutation.resume();
1123
+ resumeGate();
1124
+ await new Promise((resolve) => setTimeout(resolve, 0));
1125
+ this.isPaused = false;
1126
+ }
1127
+
1128
+ // Convergence: disk holds the writer's version, this DOM holds the merge.
1129
+ // The baseline was left pre-external, so the save sees both our retained
1130
+ // sections and the external content as changes and writes the merge back.
1131
+ if (retainedRoots > 0) {
1132
+ savePageThrottled();
1133
+ }
732
1134
  }
733
1135
 
734
1136
  /**