@panphora/clayjs 1.1.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/dist/clay.standalone.js +486 -189
- package/package.json +1 -1
- package/src/core/etag.js +17 -4
- package/src/core/host-attrs.js +52 -2
- package/src/core/is-edit-mode.js +14 -5
- package/src/core/save-conflict-notice.js +22 -27
- package/src/core/save-core.js +291 -26
- package/src/core/save.js +19 -4
- package/src/core/snapshot.js +5 -1
- package/src/core/stale-host-notice.js +95 -0
- package/src/lib/hostile-css.js +49 -0
- package/src/lib/root-attrs.js +52 -13
- package/src/loader-logic.js +5 -0
- package/src/sync/live-sync.js +184 -33
package/src/sync/live-sync.js
CHANGED
|
@@ -44,7 +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 } from '../core/etag.js';
|
|
47
|
+
import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
|
|
48
48
|
import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
|
|
49
49
|
|
|
50
50
|
// The page just took a frame verified clean against its baseline, so it now IS
|
|
@@ -114,6 +114,15 @@ class LiveSync {
|
|
|
114
114
|
|
|
115
115
|
this.debounceMs = 150;
|
|
116
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;
|
|
117
126
|
this._sendInFlight = false;
|
|
118
127
|
this._queuedSend = null;
|
|
119
128
|
|
|
@@ -155,13 +164,17 @@ class LiveSync {
|
|
|
155
164
|
this._pendingHtml = null;
|
|
156
165
|
this._pendingSeq = null;
|
|
157
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;
|
|
158
171
|
this._morphInFlight = false;
|
|
159
172
|
this._rafHandle = null;
|
|
160
173
|
|
|
161
174
|
// Disk-sourced external changes (htmlclay watcher content) get their own
|
|
162
175
|
// one-deep slot so a peer frame arriving in the same window can't
|
|
163
176
|
// displace them. Drained in seq order alongside the peer slot.
|
|
164
|
-
this._pendingExternal = null; // { html, seq, saveEpoch }
|
|
177
|
+
this._pendingExternal = null; // { html, seq, saveEpoch, etag }
|
|
165
178
|
|
|
166
179
|
// Newest external-change seq accepted for apply. Replayed or reordered
|
|
167
180
|
// disk frames (and their fetch fallbacks) are dropped against this.
|
|
@@ -274,6 +287,7 @@ class LiveSync {
|
|
|
274
287
|
// so mint a fresh resume id — this stream must not resume the previous one.
|
|
275
288
|
this.lastHtml = null;
|
|
276
289
|
this._lastIdentityMap = null;
|
|
290
|
+
this._savedSnapshot = null;
|
|
277
291
|
this._applyGen++;
|
|
278
292
|
this.lastSeenSeq = 0;
|
|
279
293
|
this._lastExternalSeq = 0;
|
|
@@ -295,7 +309,10 @@ class LiveSync {
|
|
|
295
309
|
// snapshot listener would never fire — skip registering it.
|
|
296
310
|
if (this.lane === 'live') {
|
|
297
311
|
this.listenForSnapshots();
|
|
298
|
-
this._saveSavedHandler = () => {
|
|
312
|
+
this._saveSavedHandler = () => {
|
|
313
|
+
this._saveEpoch++;
|
|
314
|
+
this._relayCommit();
|
|
315
|
+
};
|
|
299
316
|
document.addEventListener('clay:save-saved', this._saveSavedHandler);
|
|
300
317
|
}
|
|
301
318
|
}
|
|
@@ -534,31 +551,32 @@ class LiveSync {
|
|
|
534
551
|
}
|
|
535
552
|
|
|
536
553
|
/**
|
|
537
|
-
*
|
|
554
|
+
* Drop a stamp that arrived with no document (spec §6).
|
|
538
555
|
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
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.
|
|
542
559
|
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
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.
|
|
546
566
|
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
* IS the overwrite. Refusing the stamp turns it into a conflict somebody is
|
|
554
|
-
* told about.
|
|
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.
|
|
555
573
|
*
|
|
556
574
|
* @param {Object} data - The decoded frame
|
|
557
|
-
* @returns {boolean} True when this was a stamp frame and nothing should morph
|
|
575
|
+
* @returns {boolean} True when this was a stamp-only frame and nothing should morph
|
|
558
576
|
*/
|
|
559
577
|
_applyEtagFrame(data) {
|
|
560
578
|
if (typeof data.etag !== 'string' || typeof data.html === 'string') return false;
|
|
561
|
-
|
|
579
|
+
this._log('Dropping a stamp that arrived with no document: nothing proves this tab holds those bytes');
|
|
562
580
|
return true;
|
|
563
581
|
}
|
|
564
582
|
|
|
@@ -665,9 +683,21 @@ class LiveSync {
|
|
|
665
683
|
return;
|
|
666
684
|
}
|
|
667
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
|
+
|
|
668
698
|
this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
|
|
669
|
-
this.applyUpdate(html, seq, identityMap);
|
|
670
|
-
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 });
|
|
671
701
|
};
|
|
672
702
|
|
|
673
703
|
// Native EventSource auto-reconnects on transient errors
|
|
@@ -704,6 +734,12 @@ class LiveSync {
|
|
|
704
734
|
this._log('snapshot-ready received, preparing to send');
|
|
705
735
|
const html = serializeForSync(clone);
|
|
706
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
|
+
|
|
707
743
|
this.sendUpdate(html, identityMap);
|
|
708
744
|
};
|
|
709
745
|
|
|
@@ -750,6 +786,77 @@ class LiveSync {
|
|
|
750
786
|
this._postUpdate(queued.html, queued.identityMap);
|
|
751
787
|
}
|
|
752
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
|
+
|
|
753
860
|
_postUpdate(html, identityMap) {
|
|
754
861
|
// Skip if unchanged
|
|
755
862
|
if (html === this.lastHtml) {
|
|
@@ -817,7 +924,7 @@ class LiveSync {
|
|
|
817
924
|
const info = data.data;
|
|
818
925
|
if (info && info.kind === 'external-change') {
|
|
819
926
|
if (typeof info.html === 'string') {
|
|
820
|
-
this._enqueueExternal(info.html, data.seq);
|
|
927
|
+
this._enqueueExternal(info.html, data.seq, info.etag);
|
|
821
928
|
} else {
|
|
822
929
|
this._fetchExternalChange(data.seq);
|
|
823
930
|
}
|
|
@@ -830,7 +937,7 @@ class LiveSync {
|
|
|
830
937
|
return false;
|
|
831
938
|
}
|
|
832
939
|
|
|
833
|
-
_enqueueExternal(html, seq) {
|
|
940
|
+
_enqueueExternal(html, seq, etag) {
|
|
834
941
|
if (typeof seq === 'number') {
|
|
835
942
|
if (seq <= this._lastExternalSeq) {
|
|
836
943
|
this._log(`Dropping replayed external change: seq=${seq}`);
|
|
@@ -838,7 +945,12 @@ class LiveSync {
|
|
|
838
945
|
}
|
|
839
946
|
this._lastExternalSeq = seq;
|
|
840
947
|
}
|
|
841
|
-
this._pendingExternal = {
|
|
948
|
+
this._pendingExternal = {
|
|
949
|
+
html,
|
|
950
|
+
seq,
|
|
951
|
+
saveEpoch: this._saveEpoch,
|
|
952
|
+
etag: typeof etag === 'string' && etag ? etag : null,
|
|
953
|
+
};
|
|
842
954
|
this._scheduleNextFrame();
|
|
843
955
|
}
|
|
844
956
|
|
|
@@ -889,7 +1001,10 @@ class LiveSync {
|
|
|
889
1001
|
}
|
|
890
1002
|
return;
|
|
891
1003
|
}
|
|
892
|
-
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 };
|
|
893
1008
|
this._scheduleNextFrame();
|
|
894
1009
|
})
|
|
895
1010
|
.catch((err) => {
|
|
@@ -917,11 +1032,15 @@ class LiveSync {
|
|
|
917
1032
|
* @param {number} [seq] - Optional monotonic seq from the server
|
|
918
1033
|
* @param {Object} [identityMap] - Optional element-identity map from sender
|
|
919
1034
|
*/
|
|
920
|
-
applyUpdate(html, seq, identityMap) {
|
|
1035
|
+
applyUpdate(html, seq, identityMap, etag) {
|
|
921
1036
|
if (this.isDestroyed) return;
|
|
922
1037
|
this._pendingHtml = html;
|
|
923
1038
|
this._pendingSeq = seq;
|
|
924
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;
|
|
925
1044
|
this._scheduleNextFrame();
|
|
926
1045
|
}
|
|
927
1046
|
|
|
@@ -979,7 +1098,7 @@ class LiveSync {
|
|
|
979
1098
|
} else {
|
|
980
1099
|
this._morphInFlight = true;
|
|
981
1100
|
try {
|
|
982
|
-
await this._doApplyExternal(ext.html, ext.seq);
|
|
1101
|
+
await this._doApplyExternal(ext.html, ext.seq, ext.etag);
|
|
983
1102
|
} catch (err) {
|
|
984
1103
|
console.error('[LiveSync] applyExternal failed:', err);
|
|
985
1104
|
} finally {
|
|
@@ -990,14 +1109,16 @@ class LiveSync {
|
|
|
990
1109
|
const html = this._pendingHtml;
|
|
991
1110
|
const seq = this._pendingSeq;
|
|
992
1111
|
const identityMap = this._pendingIdentityMap;
|
|
1112
|
+
const etag = this._pendingEtag;
|
|
993
1113
|
this._pendingHtml = null;
|
|
994
1114
|
this._pendingSeq = null;
|
|
995
1115
|
this._pendingIdentityMap = null;
|
|
1116
|
+
this._pendingEtag = null;
|
|
996
1117
|
if (html == null) return;
|
|
997
1118
|
|
|
998
1119
|
this._morphInFlight = true;
|
|
999
1120
|
try {
|
|
1000
|
-
await this._doApplyUpdate(html, seq, identityMap);
|
|
1121
|
+
await this._doApplyUpdate(html, seq, identityMap, etag);
|
|
1001
1122
|
} catch (err) {
|
|
1002
1123
|
console.error('[LiveSync] applyUpdate failed:', err);
|
|
1003
1124
|
} finally {
|
|
@@ -1038,7 +1159,7 @@ class LiveSync {
|
|
|
1038
1159
|
* @param {Object} [identityMap]
|
|
1039
1160
|
* @returns {Promise<void>}
|
|
1040
1161
|
*/
|
|
1041
|
-
async _doApplyUpdate(html, seq, identityMap) {
|
|
1162
|
+
async _doApplyUpdate(html, seq, identityMap, etag) {
|
|
1042
1163
|
this._log('applyUpdate - pausing mutations and morphing');
|
|
1043
1164
|
this.isPaused = true;
|
|
1044
1165
|
|
|
@@ -1135,6 +1256,18 @@ class LiveSync {
|
|
|
1135
1256
|
|
|
1136
1257
|
this._setHeld('live', false);
|
|
1137
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
|
+
|
|
1138
1271
|
// Morph entire document. We MUST await — HyperMorph.morph returns a
|
|
1139
1272
|
// Promise when `scripts: { handle: true }` needs to wait for external
|
|
1140
1273
|
// scripts to load. If we don't await, Mutation.resume() fires before
|
|
@@ -1190,6 +1323,13 @@ class LiveSync {
|
|
|
1190
1323
|
: null;
|
|
1191
1324
|
this._applyGen++;
|
|
1192
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
|
+
|
|
1193
1333
|
// Cross-lane baseline: the DOM now holds this frame's content, but the
|
|
1194
1334
|
// DISK baseline (lastSavedContents) still describes pre-frame state. A
|
|
1195
1335
|
// later dirty disk apply diffing against that stale baseline would
|
|
@@ -1252,7 +1392,7 @@ class LiveSync {
|
|
|
1252
1392
|
* holds or a later dirty peer apply misclassifies this frame's content as
|
|
1253
1393
|
* local edits.
|
|
1254
1394
|
*/
|
|
1255
|
-
async _doApplyExternal(html, seq) {
|
|
1395
|
+
async _doApplyExternal(html, seq, etag = null) {
|
|
1256
1396
|
this._log(`applyExternal - external disk change (seq=${seq})`);
|
|
1257
1397
|
this.isPaused = true;
|
|
1258
1398
|
|
|
@@ -1292,7 +1432,7 @@ class LiveSync {
|
|
|
1292
1432
|
this._holdRetryExt = setTimeout(() => {
|
|
1293
1433
|
this._holdRetryExt = null;
|
|
1294
1434
|
if (this.isDestroyed || this._pendingExternal != null) return;
|
|
1295
|
-
this._pendingExternal = { html, seq, saveEpoch: epochAtHold };
|
|
1435
|
+
this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag };
|
|
1296
1436
|
this._scheduleNextFrame();
|
|
1297
1437
|
}, 3000);
|
|
1298
1438
|
return;
|
|
@@ -1329,6 +1469,17 @@ class LiveSync {
|
|
|
1329
1469
|
|
|
1330
1470
|
window.scrollTo(scrollX, scrollY);
|
|
1331
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
|
+
|
|
1332
1483
|
if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
|
|
1333
1484
|
// Clean apply: the DOM now IS the disk state, so a local comparison
|
|
1334
1485
|
// capture of it is the truthful baseline. The next no-op save skips,
|
|
@@ -1350,7 +1501,7 @@ class LiveSync {
|
|
|
1350
1501
|
}
|
|
1351
1502
|
|
|
1352
1503
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
1353
|
-
detail: { seq, source: 'disk' }
|
|
1504
|
+
detail: { seq, source: 'disk', etag: typeof etag === 'string' && etag ? etag : null }
|
|
1354
1505
|
}));
|
|
1355
1506
|
} finally {
|
|
1356
1507
|
this._log('applyExternal - morph complete, resuming mutations');
|