@panphora/clayjs 0.4.3 → 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.
- package/package.json +1 -1
- package/src/core/admin-contenteditable.js +4 -3
- package/src/core/admin-inputs.js +5 -3
- package/src/core/admin-onclick.js +4 -3
- package/src/core/admin-resources.js +8 -4
- package/src/core/is-edit-mode.js +3 -2
- package/src/core/save.js +16 -3
- package/src/core/snapshot.js +59 -4
- package/src/lib/dirty-gate.js +152 -0
- package/src/lib/root-attrs.js +5 -1
- package/src/sync/live-sync.js +440 -38
- package/src/sync/splice-merge.js +259 -0
- package/src/vendor/hyper-morph.vendor.js +4 -2
- package/src/vendor/hypercms.vendor.js +14 -14
package/src/sync/live-sync.js
CHANGED
|
@@ -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
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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
|
|
307
|
-
* @param {
|
|
359
|
+
* @param {Element} parsedRoot - parsed-tree root
|
|
360
|
+
* @param {WeakMap} parsedWeakMap - parsed node → synthetic id
|
|
308
361
|
*/
|
|
309
|
-
_fillInIdsAfterMorph(liveRoot, parsedRoot,
|
|
310
|
-
if (!liveRoot || !parsedRoot || !
|
|
311
|
-
const visit = (live, parsed
|
|
312
|
-
const id =
|
|
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]
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
this.
|
|
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
|
|
590
|
-
// frame to drain it. Without
|
|
591
|
-
// forever until the next
|
|
592
|
-
|
|
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,
|
|
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
|
/**
|