@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.
- 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 +57 -3
- package/src/core/snapshot.js +59 -4
- package/src/lib/dirty-gate.js +169 -0
- package/src/lib/root-attrs.js +5 -1
- package/src/loader-logic.js +2 -1
- package/src/loader.js +2 -0
- package/src/plugins/wire.js +598 -0
- package/src/sync/live-sync.js +489 -39
- 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
|
/**
|
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
this.
|
|
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
|
|
590
|
-
// frame to drain it. Without
|
|
591
|
-
// forever until the next
|
|
592
|
-
|
|
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,
|
|
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
|
/**
|