@panphora/clayjs 1.0.0 → 1.2.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/README.md +23 -0
- package/dist/clay.standalone.js +22327 -0
- package/entries/clay-data.js +1 -1
- package/package.json +9 -2
- package/src/core/etag.js +127 -0
- package/src/core/host-attrs.js +58 -3
- package/src/core/host-meta.js +20 -3
- package/src/core/is-edit-mode.js +14 -5
- package/src/core/save-conflict-notice.js +191 -0
- package/src/core/save-core.js +329 -7
- package/src/core/save.js +98 -4
- package/src/core/snapshot.js +38 -17
- package/src/core/stale-host-notice.js +95 -0
- package/src/lib/hostile-css.js +49 -0
- package/src/lib/root-attrs.js +62 -7
- package/src/loader-logic.js +44 -0
- package/src/loader.js +11 -5
- package/src/plugins/indicator.js +13 -2
- package/src/plugins/sortable.js +5 -5
- package/src/standalone.js +98 -0
- package/src/sync/live-sync.js +208 -15
- package/src/ui/index.js +7 -0
- package/src/vendor/hypercms.vendor.js +13 -13
- package/src/lib/load-vendor-script.js +0 -57
package/src/sync/live-sync.js
CHANGED
|
@@ -44,6 +44,7 @@ import { serializeForSync, captureForComparisonAndDirty, captureSnapshot } from
|
|
|
44
44
|
import { isTabLocalRootAttr } from '../lib/root-attrs.js';
|
|
45
45
|
import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
|
|
46
46
|
import { hostMeta } from '../core/host-meta.js';
|
|
47
|
+
import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
|
|
47
48
|
import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
|
|
48
49
|
|
|
49
50
|
// The page just took a frame verified clean against its baseline, so it now IS
|
|
@@ -113,6 +114,15 @@ class LiveSync {
|
|
|
113
114
|
|
|
114
115
|
this.debounceMs = 150;
|
|
115
116
|
this.debounceTimer = null;
|
|
117
|
+
|
|
118
|
+
// The sync serialization of the clone the CURRENT save was captured from,
|
|
119
|
+
// held so the commit relay can pair the host's stamp with the content that
|
|
120
|
+
// stamp actually describes. Set at snapshot-ready, which fires before the
|
|
121
|
+
// save POST goes out, and consumed by _relayCommit when the response lands.
|
|
122
|
+
// Never reconstructed from lastHtml: that is the last relay that COMPLETED,
|
|
123
|
+
// which lags the save whenever the response beats the 150ms debounce, and
|
|
124
|
+
// pairing a fresh stamp with older bytes is the one thing spec §10 forbids.
|
|
125
|
+
this._savedSnapshot = null;
|
|
116
126
|
this._sendInFlight = false;
|
|
117
127
|
this._queuedSend = null;
|
|
118
128
|
|
|
@@ -154,13 +164,17 @@ class LiveSync {
|
|
|
154
164
|
this._pendingHtml = null;
|
|
155
165
|
this._pendingSeq = null;
|
|
156
166
|
this._pendingIdentityMap = null;
|
|
167
|
+
// The stamp a pending frame carries, adopted only if that frame applies
|
|
168
|
+
// cleanly. It rides in the slot beside the bytes it describes so the two can
|
|
169
|
+
// never be separated, which is the whole rule (§6, and §22 of the plan).
|
|
170
|
+
this._pendingEtag = null;
|
|
157
171
|
this._morphInFlight = false;
|
|
158
172
|
this._rafHandle = null;
|
|
159
173
|
|
|
160
174
|
// Disk-sourced external changes (htmlclay watcher content) get their own
|
|
161
175
|
// one-deep slot so a peer frame arriving in the same window can't
|
|
162
176
|
// displace them. Drained in seq order alongside the peer slot.
|
|
163
|
-
this._pendingExternal = null; // { html, seq, saveEpoch }
|
|
177
|
+
this._pendingExternal = null; // { html, seq, saveEpoch, etag }
|
|
164
178
|
|
|
165
179
|
// Newest external-change seq accepted for apply. Replayed or reordered
|
|
166
180
|
// disk frames (and their fetch fallbacks) are dropped against this.
|
|
@@ -273,6 +287,7 @@ class LiveSync {
|
|
|
273
287
|
// so mint a fresh resume id — this stream must not resume the previous one.
|
|
274
288
|
this.lastHtml = null;
|
|
275
289
|
this._lastIdentityMap = null;
|
|
290
|
+
this._savedSnapshot = null;
|
|
276
291
|
this._applyGen++;
|
|
277
292
|
this.lastSeenSeq = 0;
|
|
278
293
|
this._lastExternalSeq = 0;
|
|
@@ -294,7 +309,10 @@ class LiveSync {
|
|
|
294
309
|
// snapshot listener would never fire — skip registering it.
|
|
295
310
|
if (this.lane === 'live') {
|
|
296
311
|
this.listenForSnapshots();
|
|
297
|
-
this._saveSavedHandler = () => {
|
|
312
|
+
this._saveSavedHandler = () => {
|
|
313
|
+
this._saveEpoch++;
|
|
314
|
+
this._relayCommit();
|
|
315
|
+
};
|
|
298
316
|
document.addEventListener('clay:save-saved', this._saveSavedHandler);
|
|
299
317
|
}
|
|
300
318
|
}
|
|
@@ -517,11 +535,51 @@ class LiveSync {
|
|
|
517
535
|
const key = lane === 'live' ? '_heldLive' : '_heldExt';
|
|
518
536
|
if (this[key] === isHeld) return;
|
|
519
537
|
this[key] = isHeld;
|
|
538
|
+
// A hold that clears leaves this tab back in step, but every stamp frame that
|
|
539
|
+
// arrived while it was held was refused and nothing re-sends them: stamps are
|
|
540
|
+
// broadcast on a save, not on a merge. So the tab would go on holding a stamp
|
|
541
|
+
// from before the hold and have its next save refused over a conflict that has
|
|
542
|
+
// already resolved itself, which is this notice crying wolf. Ask the host for
|
|
543
|
+
// the current one instead. Only once BOTH lanes are clear, because one lane
|
|
544
|
+
// resuming while the other still holds means the tab is still behind.
|
|
545
|
+
if (!isHeld && !this._heldLive && !this._heldExt) {
|
|
546
|
+
seedEtag({ fresh: true, clearIfMissing: false });
|
|
547
|
+
}
|
|
520
548
|
document.dispatchEvent(new CustomEvent(isHeld ? 'clay:sync-held' : 'clay:sync-resumed', {
|
|
521
549
|
detail: { lane, el: el || null },
|
|
522
550
|
}));
|
|
523
551
|
}
|
|
524
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Drop a stamp that arrived with no document (spec §6).
|
|
555
|
+
*
|
|
556
|
+
* ⚠️ This used to TAKE it, and taking it was wrong. A stamp says "disk is at this
|
|
557
|
+
* version"; adopting it says "and I hold those bytes". Only the second claim
|
|
558
|
+
* makes the next save safe, and a frame with no content is no evidence for it.
|
|
559
|
+
*
|
|
560
|
+
* The race is real and it loses work. A peer's save and that peer's snapshot
|
|
561
|
+
* relay are two concurrent requests, so a stamp travelling on its own can arrive
|
|
562
|
+
* first. This tab would record it while its DOM still held the older content,
|
|
563
|
+
* and its next save would then pass If-Match and overwrite the save it had never
|
|
564
|
+
* received. The hold flags cannot cover that: a lane holds when an incoming
|
|
565
|
+
* change could NOT be merged, not when one has yet to arrive.
|
|
566
|
+
*
|
|
567
|
+
* So a stamp now rides on the snapshot the saving tab relays after its save
|
|
568
|
+
* returns, and `_doApplyUpdate` records it only once that content has merged.
|
|
569
|
+
* Nothing sends a bare stamp any more — hyperclay's was deleted, and it had
|
|
570
|
+
* never reached anyone, because the relay library refuses a payload with no
|
|
571
|
+
* document in it. This stays as the rule rather than as compatibility: if some
|
|
572
|
+
* host does send one, dropping it is correct.
|
|
573
|
+
*
|
|
574
|
+
* @param {Object} data - The decoded frame
|
|
575
|
+
* @returns {boolean} True when this was a stamp-only frame and nothing should morph
|
|
576
|
+
*/
|
|
577
|
+
_applyEtagFrame(data) {
|
|
578
|
+
if (typeof data.etag !== 'string' || typeof data.html === 'string') return false;
|
|
579
|
+
this._log('Dropping a stamp that arrived with no document: nothing proves this tab holds those bytes');
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
|
|
525
583
|
/**
|
|
526
584
|
* Connect to the SSE endpoint
|
|
527
585
|
* Uses native EventSource reconnection behavior
|
|
@@ -609,6 +667,8 @@ class LiveSync {
|
|
|
609
667
|
return;
|
|
610
668
|
}
|
|
611
669
|
|
|
670
|
+
if (this._applyEtagFrame(data)) return;
|
|
671
|
+
|
|
612
672
|
const { html, sender, identityMap } = data;
|
|
613
673
|
|
|
614
674
|
// Ignore own changes — already reflected in the DOM, nothing to morph
|
|
@@ -623,9 +683,21 @@ class LiveSync {
|
|
|
623
683
|
return;
|
|
624
684
|
}
|
|
625
685
|
|
|
686
|
+
const etag = typeof data.etag === 'string' && data.etag ? data.etag : null;
|
|
687
|
+
|
|
688
|
+
// The common case, and it costs nothing: a peer saved bytes this tab has
|
|
689
|
+
// already applied, so the frame's only news is the stamp. Taking it without
|
|
690
|
+
// a morph is exactly as safe as taking it after one, because the baseline
|
|
691
|
+
// being equal IS the proof that this tab holds those bytes.
|
|
692
|
+
if (etag && html === this.lastHtml) {
|
|
693
|
+
this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
|
|
694
|
+
recordEtag(etag);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
626
698
|
this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
|
|
627
|
-
this.applyUpdate(html, seq, identityMap);
|
|
628
|
-
if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap });
|
|
699
|
+
this.applyUpdate(html, seq, identityMap, etag);
|
|
700
|
+
if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap, etag });
|
|
629
701
|
};
|
|
630
702
|
|
|
631
703
|
// Native EventSource auto-reconnects on transient errors
|
|
@@ -662,6 +734,12 @@ class LiveSync {
|
|
|
662
734
|
this._log('snapshot-ready received, preparing to send');
|
|
663
735
|
const html = serializeForSync(clone);
|
|
664
736
|
const identityMap = this._buildIdentityMap(document.documentElement, clone);
|
|
737
|
+
|
|
738
|
+
// The save that follows this capture stores these bytes, so this is the
|
|
739
|
+
// content its stamp will describe. Held for _relayCommit, and overwritten
|
|
740
|
+
// by the next capture, so it always names the save currently in flight.
|
|
741
|
+
this._savedSnapshot = { html, identityMap };
|
|
742
|
+
|
|
665
743
|
this.sendUpdate(html, identityMap);
|
|
666
744
|
};
|
|
667
745
|
|
|
@@ -708,6 +786,77 @@ class LiveSync {
|
|
|
708
786
|
this._postUpdate(queued.html, queued.identityMap);
|
|
709
787
|
}
|
|
710
788
|
|
|
789
|
+
/**
|
|
790
|
+
* Tell the other editors what this tab's save stored, and under which stamp.
|
|
791
|
+
*
|
|
792
|
+
* Runs on clay:save-saved, by which point the save response has already been
|
|
793
|
+
* recorded, so `lastSeenEtag()` is the stamp for the bytes that just landed.
|
|
794
|
+
*
|
|
795
|
+
* The content is `lastHtml`, the snapshot this tab most recently relayed, which
|
|
796
|
+
* is the state the save was taken from: the save pipeline dispatches
|
|
797
|
+
* clay:snapshot-ready on its way to capturing what to send, so that relay has
|
|
798
|
+
* already gone out. Re-sending those same bytes is not redundant, because the
|
|
799
|
+
* stamp is the new information and a stamp may never travel without the content
|
|
800
|
+
* it describes. A receiver whose baseline already matches takes the stamp and
|
|
801
|
+
* skips the morph.
|
|
802
|
+
*/
|
|
803
|
+
_relayCommit() {
|
|
804
|
+
// Holding a stamp is the whole condition. It is set only from a save response
|
|
805
|
+
// that carried one, and a host that does not do conditional saves returns
|
|
806
|
+
// none, so this is the same test as "the host stamps what it stores" without
|
|
807
|
+
// a second flag that would have to be reached through discovery to exercise.
|
|
808
|
+
const etag = lastSeenEtag();
|
|
809
|
+
if (!etag) return;
|
|
810
|
+
if (this.isDestroyed || this.isPaused) return;
|
|
811
|
+
|
|
812
|
+
// The content this save stored, captured with it. Consumed rather than left
|
|
813
|
+
// behind, so a save-saved with no capture of its own can never reuse an
|
|
814
|
+
// older save's bytes under a newer stamp.
|
|
815
|
+
const pending = this._savedSnapshot;
|
|
816
|
+
this._savedSnapshot = null;
|
|
817
|
+
|
|
818
|
+
// No captured snapshot means nothing here knows which bytes this stamp
|
|
819
|
+
// describes, and §10 is explicit that a stamp must never travel on its own.
|
|
820
|
+
// Staying silent costs the other editors one refusal they recover from;
|
|
821
|
+
// guessing costs somebody their work.
|
|
822
|
+
if (!pending || typeof pending.html !== 'string') return;
|
|
823
|
+
|
|
824
|
+
this._postCommit(pending.html, etag, pending.identityMap);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Post a snapshot whose point is the stamp attached to it.
|
|
829
|
+
*
|
|
830
|
+
* Deliberately not `_postUpdate`: that one returns early when the html matches
|
|
831
|
+
* `lastHtml`, and the whole point here is that the stamp is the new information
|
|
832
|
+
* even when the bytes are not. It also does not touch `lastHtml` or the
|
|
833
|
+
* single-flight queue, because it must not displace a real snapshot waiting to
|
|
834
|
+
* go out. It carries the identityMap captured with these bytes, so a receiver
|
|
835
|
+
* that morphs on this frame pairs elements the same way it would on the
|
|
836
|
+
* ordinary relay of the same content.
|
|
837
|
+
*/
|
|
838
|
+
_postCommit(html, etag, identityMap) {
|
|
839
|
+
const profile = this._profile;
|
|
840
|
+
if (!profile) return;
|
|
841
|
+
fetch(new URL(profile.relayPath, window.location.origin).href, {
|
|
842
|
+
method: 'POST',
|
|
843
|
+
headers: {
|
|
844
|
+
'Content-Type': 'application/json',
|
|
845
|
+
[profile.documentHeader]: window.location.href,
|
|
846
|
+
},
|
|
847
|
+
body: JSON.stringify({
|
|
848
|
+
[profile.snapshotKey]: html,
|
|
849
|
+
sender: this.clientId,
|
|
850
|
+
identityMap,
|
|
851
|
+
etag
|
|
852
|
+
})
|
|
853
|
+
}).catch(err => {
|
|
854
|
+
// Losing this costs the other editors one spurious refusal on their next
|
|
855
|
+
// save, which they recover from. It is not worth surfacing as an error.
|
|
856
|
+
this._log('Commit relay failed: ' + (err && err.message));
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
711
860
|
_postUpdate(html, identityMap) {
|
|
712
861
|
// Skip if unchanged
|
|
713
862
|
if (html === this.lastHtml) {
|
|
@@ -775,7 +924,7 @@ class LiveSync {
|
|
|
775
924
|
const info = data.data;
|
|
776
925
|
if (info && info.kind === 'external-change') {
|
|
777
926
|
if (typeof info.html === 'string') {
|
|
778
|
-
this._enqueueExternal(info.html, data.seq);
|
|
927
|
+
this._enqueueExternal(info.html, data.seq, info.etag);
|
|
779
928
|
} else {
|
|
780
929
|
this._fetchExternalChange(data.seq);
|
|
781
930
|
}
|
|
@@ -788,7 +937,7 @@ class LiveSync {
|
|
|
788
937
|
return false;
|
|
789
938
|
}
|
|
790
939
|
|
|
791
|
-
_enqueueExternal(html, seq) {
|
|
940
|
+
_enqueueExternal(html, seq, etag) {
|
|
792
941
|
if (typeof seq === 'number') {
|
|
793
942
|
if (seq <= this._lastExternalSeq) {
|
|
794
943
|
this._log(`Dropping replayed external change: seq=${seq}`);
|
|
@@ -796,7 +945,12 @@ class LiveSync {
|
|
|
796
945
|
}
|
|
797
946
|
this._lastExternalSeq = seq;
|
|
798
947
|
}
|
|
799
|
-
this._pendingExternal = {
|
|
948
|
+
this._pendingExternal = {
|
|
949
|
+
html,
|
|
950
|
+
seq,
|
|
951
|
+
saveEpoch: this._saveEpoch,
|
|
952
|
+
etag: typeof etag === 'string' && etag ? etag : null,
|
|
953
|
+
};
|
|
800
954
|
this._scheduleNextFrame();
|
|
801
955
|
}
|
|
802
956
|
|
|
@@ -847,7 +1001,10 @@ class LiveSync {
|
|
|
847
1001
|
}
|
|
848
1002
|
return;
|
|
849
1003
|
}
|
|
850
|
-
this
|
|
1004
|
+
// No stamp: this body came from a GET of the served page, which nobody
|
|
1005
|
+
// stamped, so the apply leaves the held stamp alone and etag.js asks the
|
|
1006
|
+
// host for a replacement.
|
|
1007
|
+
this._pendingExternal = { html, seq, saveEpoch: epoch, etag: null };
|
|
851
1008
|
this._scheduleNextFrame();
|
|
852
1009
|
})
|
|
853
1010
|
.catch((err) => {
|
|
@@ -875,11 +1032,15 @@ class LiveSync {
|
|
|
875
1032
|
* @param {number} [seq] - Optional monotonic seq from the server
|
|
876
1033
|
* @param {Object} [identityMap] - Optional element-identity map from sender
|
|
877
1034
|
*/
|
|
878
|
-
applyUpdate(html, seq, identityMap) {
|
|
1035
|
+
applyUpdate(html, seq, identityMap, etag) {
|
|
879
1036
|
if (this.isDestroyed) return;
|
|
880
1037
|
this._pendingHtml = html;
|
|
881
1038
|
this._pendingSeq = seq;
|
|
882
1039
|
this._pendingIdentityMap = identityMap;
|
|
1040
|
+
// A newer frame replacing this one takes its stamp with it, and that is
|
|
1041
|
+
// correct: the stamp belongs to bytes that are no longer what will apply.
|
|
1042
|
+
// Losing it costs one honest 412 later, which is the safe direction.
|
|
1043
|
+
this._pendingEtag = etag ?? null;
|
|
883
1044
|
this._scheduleNextFrame();
|
|
884
1045
|
}
|
|
885
1046
|
|
|
@@ -937,7 +1098,7 @@ class LiveSync {
|
|
|
937
1098
|
} else {
|
|
938
1099
|
this._morphInFlight = true;
|
|
939
1100
|
try {
|
|
940
|
-
await this._doApplyExternal(ext.html, ext.seq);
|
|
1101
|
+
await this._doApplyExternal(ext.html, ext.seq, ext.etag);
|
|
941
1102
|
} catch (err) {
|
|
942
1103
|
console.error('[LiveSync] applyExternal failed:', err);
|
|
943
1104
|
} finally {
|
|
@@ -948,14 +1109,16 @@ class LiveSync {
|
|
|
948
1109
|
const html = this._pendingHtml;
|
|
949
1110
|
const seq = this._pendingSeq;
|
|
950
1111
|
const identityMap = this._pendingIdentityMap;
|
|
1112
|
+
const etag = this._pendingEtag;
|
|
951
1113
|
this._pendingHtml = null;
|
|
952
1114
|
this._pendingSeq = null;
|
|
953
1115
|
this._pendingIdentityMap = null;
|
|
1116
|
+
this._pendingEtag = null;
|
|
954
1117
|
if (html == null) return;
|
|
955
1118
|
|
|
956
1119
|
this._morphInFlight = true;
|
|
957
1120
|
try {
|
|
958
|
-
await this._doApplyUpdate(html, seq, identityMap);
|
|
1121
|
+
await this._doApplyUpdate(html, seq, identityMap, etag);
|
|
959
1122
|
} catch (err) {
|
|
960
1123
|
console.error('[LiveSync] applyUpdate failed:', err);
|
|
961
1124
|
} finally {
|
|
@@ -996,7 +1159,7 @@ class LiveSync {
|
|
|
996
1159
|
* @param {Object} [identityMap]
|
|
997
1160
|
* @returns {Promise<void>}
|
|
998
1161
|
*/
|
|
999
|
-
async _doApplyUpdate(html, seq, identityMap) {
|
|
1162
|
+
async _doApplyUpdate(html, seq, identityMap, etag) {
|
|
1000
1163
|
this._log('applyUpdate - pausing mutations and morphing');
|
|
1001
1164
|
this.isPaused = true;
|
|
1002
1165
|
|
|
@@ -1093,6 +1256,18 @@ class LiveSync {
|
|
|
1093
1256
|
|
|
1094
1257
|
this._setHeld('live', false);
|
|
1095
1258
|
|
|
1259
|
+
// The frame merged, so this tab may take its stamp — but not yet. Adopting
|
|
1260
|
+
// the stamp and applying the bytes have to be the same event, and they only
|
|
1261
|
+
// are once the apply has actually happened. The morph below is awaited and
|
|
1262
|
+
// can reject, and a stamp recorded before it would sit on the old DOM with
|
|
1263
|
+
// nothing to undo it, so the next save would pass If-Match and replace a
|
|
1264
|
+
// version this tab never received. It is recorded after the await, beside
|
|
1265
|
+
// `lastHtml`, on the one path where the bytes definitely landed.
|
|
1266
|
+
//
|
|
1267
|
+
// The hold path above returns before that point, deliberately. A held tab is
|
|
1268
|
+
// knowingly missing what disk holds, and a stamp there would let its next
|
|
1269
|
+
// save replace that change with nobody told.
|
|
1270
|
+
|
|
1096
1271
|
// Morph entire document. We MUST await — HyperMorph.morph returns a
|
|
1097
1272
|
// Promise when `scripts: { handle: true }` needs to wait for external
|
|
1098
1273
|
// scripts to load. If we don't await, Mutation.resume() fires before
|
|
@@ -1148,6 +1323,13 @@ class LiveSync {
|
|
|
1148
1323
|
: null;
|
|
1149
1324
|
this._applyGen++;
|
|
1150
1325
|
|
|
1326
|
+
// The stamp of section 6, taken now that the bytes it describes are the
|
|
1327
|
+
// bytes this tab is holding. Same line of reasoning as lastHtml directly
|
|
1328
|
+
// above, and deliberately the same moment: the two claims a page makes
|
|
1329
|
+
// when it adopts a stamp are "the host stores this version" and "I hold
|
|
1330
|
+
// it", and only the second one is this tab's to make.
|
|
1331
|
+
if (typeof etag === 'string' && etag) recordEtag(etag);
|
|
1332
|
+
|
|
1151
1333
|
// Cross-lane baseline: the DOM now holds this frame's content, but the
|
|
1152
1334
|
// DISK baseline (lastSavedContents) still describes pre-frame state. A
|
|
1153
1335
|
// later dirty disk apply diffing against that stale baseline would
|
|
@@ -1210,7 +1392,7 @@ class LiveSync {
|
|
|
1210
1392
|
* holds or a later dirty peer apply misclassifies this frame's content as
|
|
1211
1393
|
* local edits.
|
|
1212
1394
|
*/
|
|
1213
|
-
async _doApplyExternal(html, seq) {
|
|
1395
|
+
async _doApplyExternal(html, seq, etag = null) {
|
|
1214
1396
|
this._log(`applyExternal - external disk change (seq=${seq})`);
|
|
1215
1397
|
this.isPaused = true;
|
|
1216
1398
|
|
|
@@ -1250,7 +1432,7 @@ class LiveSync {
|
|
|
1250
1432
|
this._holdRetryExt = setTimeout(() => {
|
|
1251
1433
|
this._holdRetryExt = null;
|
|
1252
1434
|
if (this.isDestroyed || this._pendingExternal != null) return;
|
|
1253
|
-
this._pendingExternal = { html, seq, saveEpoch: epochAtHold };
|
|
1435
|
+
this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag };
|
|
1254
1436
|
this._scheduleNextFrame();
|
|
1255
1437
|
}, 3000);
|
|
1256
1438
|
return;
|
|
@@ -1287,6 +1469,17 @@ class LiveSync {
|
|
|
1287
1469
|
|
|
1288
1470
|
window.scrollTo(scrollX, scrollY);
|
|
1289
1471
|
|
|
1472
|
+
// The stamp of section 6, adopted here and nowhere else: this is the moment
|
|
1473
|
+
// the bytes it describes reached this tab. It is taken on the retained-roots
|
|
1474
|
+
// path too, because a merge still incorporates the disk bytes, and the
|
|
1475
|
+
// convergence save at the bottom needs a stamp the host will accept or the
|
|
1476
|
+
// merge is refused and lost.
|
|
1477
|
+
//
|
|
1478
|
+
// A frame with no stamp (an older host, or the content-less fetch fallback,
|
|
1479
|
+
// which serves bytes nobody stamped) leaves this alone, and the listener in
|
|
1480
|
+
// etag.js falls back to asking the host.
|
|
1481
|
+
if (typeof etag === 'string' && etag) recordEtag(etag);
|
|
1482
|
+
|
|
1290
1483
|
if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
|
|
1291
1484
|
// Clean apply: the DOM now IS the disk state, so a local comparison
|
|
1292
1485
|
// capture of it is the truthful baseline. The next no-op save skips,
|
|
@@ -1308,7 +1501,7 @@ class LiveSync {
|
|
|
1308
1501
|
}
|
|
1309
1502
|
|
|
1310
1503
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
1311
|
-
detail: { seq, source: 'disk' }
|
|
1504
|
+
detail: { seq, source: 'disk', etag: typeof etag === 'string' && etag ? etag : null }
|
|
1312
1505
|
}));
|
|
1313
1506
|
} finally {
|
|
1314
1507
|
this._log('applyExternal - morph complete, resuming mutations');
|
package/src/ui/index.js
CHANGED
|
@@ -23,6 +23,13 @@ if (typeof window.toastPersistent === "undefined") window.toastPersistent = toas
|
|
|
23
23
|
document.addEventListener("clay:save-saved", (e) =>
|
|
24
24
|
toast(e.detail?.msg || "Saved", e.detail?.msgType === "warning" ? "warning" : "success"));
|
|
25
25
|
document.addEventListener("clay:save-error", () => toastPersistent("Couldn't save", "error"));
|
|
26
|
+
// Spec §6. Persistent, and carrying the host's own words, because this is the one
|
|
27
|
+
// save outcome the person has to act on: their edits are safe and unsaved, and
|
|
28
|
+
// autosave has stopped until they choose. Deliberately a toast and not a dialog:
|
|
29
|
+
// most conflicts surface on an autosave nobody asked for, and a modal thrown over
|
|
30
|
+
// the page a person is typing into is worse than the problem it reports.
|
|
31
|
+
document.addEventListener("clay:save-conflict", (e) =>
|
|
32
|
+
toastPersistent(e.detail?.msg || "This document changed since you opened it", "warning"));
|
|
26
33
|
document.addEventListener("clay:save-offline", () => toastPersistent("Offline, not saved", "warning"));
|
|
27
34
|
|
|
28
35
|
export { toast, toastPersistent, ask, consent, tell, snippet, themodal };
|