@panphora/clayjs 1.5.0 → 1.5.2
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 +2358 -2304
- package/package.json +1 -1
- package/src/core/etag.js +4 -1
- package/src/core/save-conflict-notice.js +1 -0
- package/src/core/save-core.js +4 -0
- package/src/core/save.js +35 -3
- package/src/core/snapshot.js +1 -1
- package/src/sync/live-sync.js +104 -91
- package/src/vendor/hyper-morph.vendor.js +4 -3
package/package.json
CHANGED
package/src/core/etag.js
CHANGED
|
@@ -82,7 +82,10 @@ export function forgetEtag() {
|
|
|
82
82
|
export async function seedEtag({ fresh = false, clearIfMissing = fresh } = {}) {
|
|
83
83
|
const at = generation;
|
|
84
84
|
const meta = await hostMeta({ fresh });
|
|
85
|
-
|
|
85
|
+
// Only a real answer (spec is a number) says what the host supports: a refresh
|
|
86
|
+
// that failed comes back as a bare host with no extensions, and must not switch
|
|
87
|
+
// conditional saves off for the rest of the page's life.
|
|
88
|
+
if (typeof meta.spec === "number") conditional = meta.extensions.includes("conditional");
|
|
86
89
|
|
|
87
90
|
if (generation !== at) return lastSeen;
|
|
88
91
|
|
|
@@ -185,6 +185,7 @@ function init() {
|
|
|
185
185
|
if (!isEditMode) return;
|
|
186
186
|
document.addEventListener("clay:save-conflict", show);
|
|
187
187
|
document.addEventListener("clay:save-saved", hide);
|
|
188
|
+
document.addEventListener("clay:save-conflict-resolved", hide);
|
|
188
189
|
document.addEventListener("keydown", onKeydown);
|
|
189
190
|
}
|
|
190
191
|
|
package/src/core/save-core.js
CHANGED
|
@@ -208,6 +208,10 @@ function errorResult(err) {
|
|
|
208
208
|
// A refusal that may be answering this tab's own timed-out write. Only the
|
|
209
209
|
// notice uses it, and only to word itself; nothing decides anything on it.
|
|
210
210
|
afterTimeout: conflicted && unknownAttempt !== null,
|
|
211
|
+
// The stamp the host refused this save against: the version that beat it.
|
|
212
|
+
// Live sync compares it with the frames it merges to know when the refusal
|
|
213
|
+
// is answered.
|
|
214
|
+
conflictEtag: conflicted ? (err.etag ?? null) : null,
|
|
211
215
|
etag: null
|
|
212
216
|
};
|
|
213
217
|
}
|
package/src/core/save.js
CHANGED
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
} from "./save-core.js";
|
|
23
23
|
import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
|
|
24
24
|
import { seedEtag } from "./etag.js";
|
|
25
|
-
import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
|
|
25
|
+
import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
|
|
26
|
+
import { autosaveActive } from "../lib/autosave-state.js";
|
|
26
27
|
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
|
|
27
28
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
28
29
|
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
@@ -227,8 +228,10 @@ export function resumeAutosave() {
|
|
|
227
228
|
// The hold is released when a save lands, whatever produced it: clay.save.overwrite,
|
|
228
229
|
// a live-sync frame that brought the page back in step, or the other tab going away.
|
|
229
230
|
let conflictHold = false;
|
|
231
|
+
let conflictEtag = null;
|
|
230
232
|
|
|
231
|
-
function holdForConflict() {
|
|
233
|
+
function holdForConflict(etag) {
|
|
234
|
+
conflictEtag = etag ?? null;
|
|
232
235
|
if (conflictHold) return;
|
|
233
236
|
conflictHold = true;
|
|
234
237
|
suspendAutosave();
|
|
@@ -237,6 +240,7 @@ function holdForConflict() {
|
|
|
237
240
|
function releaseConflictHold() {
|
|
238
241
|
if (!conflictHold) return;
|
|
239
242
|
conflictHold = false;
|
|
243
|
+
conflictEtag = null;
|
|
240
244
|
resumeAutosave();
|
|
241
245
|
}
|
|
242
246
|
|
|
@@ -245,6 +249,33 @@ export function isSaveConflicted() {
|
|
|
245
249
|
return conflictHold;
|
|
246
250
|
}
|
|
247
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Live sync merged the version the host refused this tab over, and the tab now
|
|
254
|
+
* holds that version's stamp, so the next save carries a stamp the host accepts.
|
|
255
|
+
* Only that version answers the refusal: a frame with any other stamp (an older
|
|
256
|
+
* save arriving late) leaves the hold alone. Autosave resumes, replaying one
|
|
257
|
+
* missed save. On a manual-save page with unsaved work the root stays in
|
|
258
|
+
* 'conflict' and the notice stays up until the person saves, since nothing has
|
|
259
|
+
* been written yet. Not clay:save-saved: no save happened, and that event runs
|
|
260
|
+
* every [onaftersave] handler.
|
|
261
|
+
*/
|
|
262
|
+
export function conflictResolvedBySync(etag) {
|
|
263
|
+
if (!conflictHold) return;
|
|
264
|
+
if (conflictEtag && etag !== conflictEtag) return;
|
|
265
|
+
conflictHold = false;
|
|
266
|
+
conflictEtag = null;
|
|
267
|
+
const awaitingManualSave = !autosaveActive() && pageMaybeDirty();
|
|
268
|
+
if (!awaitingManualSave) {
|
|
269
|
+
if (document.documentElement.getAttribute('savestatus') === 'conflict') {
|
|
270
|
+
document.documentElement.setAttribute('savestatus', 'saved');
|
|
271
|
+
}
|
|
272
|
+
document.dispatchEvent(new CustomEvent('clay:save-conflict-resolved', {
|
|
273
|
+
detail: { timestamp: Date.now() }
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
resumeAutosave();
|
|
277
|
+
}
|
|
278
|
+
|
|
248
279
|
function skipped_(msg) {
|
|
249
280
|
return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
|
|
250
281
|
}
|
|
@@ -275,10 +306,11 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
|
|
|
275
306
|
logBaseline(label, `${lastSavedContents.length} chars`);
|
|
276
307
|
releaseConflictHold();
|
|
277
308
|
} else if (result.msgType === 'conflict') {
|
|
278
|
-
holdForConflict();
|
|
309
|
+
holdForConflict(result.conflictEtag);
|
|
279
310
|
setSaveState('conflict', result.msg, result.msgType, {
|
|
280
311
|
changedBy: result.changedBy ?? null,
|
|
281
312
|
afterTimeout: result.afterTimeout === true,
|
|
313
|
+
etag: result.conflictEtag ?? null,
|
|
282
314
|
});
|
|
283
315
|
} else if (result.msgType !== 'skipped') {
|
|
284
316
|
if (!navigator.onLine) {
|
package/src/core/snapshot.js
CHANGED
|
@@ -464,7 +464,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
|
|
|
464
464
|
// and a baseline the dirty check cannot reproduce warns on close forever.
|
|
465
465
|
if (emitForSync) {
|
|
466
466
|
document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
|
|
467
|
-
detail: { documentElement: clone }
|
|
467
|
+
detail: { documentElement: clone, forSave: true }
|
|
468
468
|
}));
|
|
469
469
|
}
|
|
470
470
|
|
package/src/sync/live-sync.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* ▼
|
|
12
12
|
* ┌─────────────────────────────────────────────────────────┐
|
|
13
13
|
* │ 2. SEND POST snapshot to the relay address │
|
|
14
|
-
* │
|
|
14
|
+
* │ (after the save lands, with its stamp) │
|
|
15
15
|
* └─────────────────────────────────────────────────────────┘
|
|
16
16
|
* │
|
|
17
17
|
* ▼
|
|
@@ -55,7 +55,7 @@ import { presence } from './presence.js';
|
|
|
55
55
|
// `clay:sync-applied`, which this file is the only dispatcher of.
|
|
56
56
|
import './section-notice.js';
|
|
57
57
|
import { hostMeta } from '../core/host-meta.js';
|
|
58
|
-
import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
|
|
58
|
+
import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves } from '../core/etag.js';
|
|
59
59
|
import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged, gateMarkDirty } from '../lib/dirty-gate.js';
|
|
60
60
|
import { SyncStream } from './stream.js';
|
|
61
61
|
|
|
@@ -144,7 +144,7 @@ function pairedBaseline() {
|
|
|
144
144
|
const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
|
|
145
145
|
return [forComparison, forDirty];
|
|
146
146
|
}
|
|
147
|
-
import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes } from '../core/save.js';
|
|
147
|
+
import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted } from '../core/save.js';
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
150
|
* The two live-sync wires, and the rule for choosing between them.
|
|
@@ -227,15 +227,12 @@ class LiveSync {
|
|
|
227
227
|
// durable per-tab identity used for echo suppression.
|
|
228
228
|
this.resumeId = null;
|
|
229
229
|
|
|
230
|
-
this.debounceMs = 150;
|
|
231
|
-
this.debounceTimer = null;
|
|
232
|
-
|
|
233
230
|
// The sync serialization of the clone the CURRENT save was captured from,
|
|
234
231
|
// held so the commit relay can pair the host's stamp with the content that
|
|
235
232
|
// stamp actually describes. Set at snapshot-ready, which fires before the
|
|
236
233
|
// save POST goes out, and consumed by _relayCommit when the response lands.
|
|
237
234
|
// Never reconstructed from lastHtml: that is the last relay that COMPLETED,
|
|
238
|
-
// which lags the save whenever
|
|
235
|
+
// which lags the save whenever an earlier relay is still in flight, and
|
|
239
236
|
// pairing a fresh stamp with older bytes is the one thing spec §10 forbids.
|
|
240
237
|
this._savedSnapshot = null;
|
|
241
238
|
this._sendInFlight = false;
|
|
@@ -305,6 +302,7 @@ class LiveSync {
|
|
|
305
302
|
// equal our state.
|
|
306
303
|
this._saveEpoch = 0;
|
|
307
304
|
this._saveSavedHandler = null;
|
|
305
|
+
this._saveConflictHandler = null;
|
|
308
306
|
|
|
309
307
|
// The identityMap that came with lastHtml, so the peer-lane dirty diff
|
|
310
308
|
// can resolve synthetic identities on its base tree.
|
|
@@ -467,6 +465,23 @@ class LiveSync {
|
|
|
467
465
|
this._relayCommit();
|
|
468
466
|
};
|
|
469
467
|
document.addEventListener('clay:save-saved', this._saveSavedHandler);
|
|
468
|
+
// A 412 can arrive after this tab already merged the version that beat it:
|
|
469
|
+
// the winner's relay overtook the refusal. The refusal then names a stamp
|
|
470
|
+
// this tab already holds, and nothing else would ever release the hold.
|
|
471
|
+
// Deferred a microtask so the conflict notice's own listener has shown the
|
|
472
|
+
// bar before this hides it.
|
|
473
|
+
this._saveConflictHandler = (event) => {
|
|
474
|
+
const refusedBy = event.detail && event.detail.etag;
|
|
475
|
+
if (!refusedBy) return;
|
|
476
|
+
queueMicrotask(() => {
|
|
477
|
+
if (this.isDestroyed) return;
|
|
478
|
+
if (refusedBy !== lastSeenEtag()) return;
|
|
479
|
+
if (this.unresolvedConflicts.length) return;
|
|
480
|
+
conflictResolvedBySync(refusedBy);
|
|
481
|
+
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
document.addEventListener('clay:save-conflict', this._saveConflictHandler);
|
|
470
485
|
}
|
|
471
486
|
}
|
|
472
487
|
|
|
@@ -494,7 +509,11 @@ class LiveSync {
|
|
|
494
509
|
this._saveSavedHandler = null;
|
|
495
510
|
}
|
|
496
511
|
|
|
497
|
-
|
|
512
|
+
if (this._saveConflictHandler) {
|
|
513
|
+
document.removeEventListener('clay:save-conflict', this._saveConflictHandler);
|
|
514
|
+
this._saveConflictHandler = null;
|
|
515
|
+
}
|
|
516
|
+
|
|
498
517
|
this._queuedSend = null;
|
|
499
518
|
|
|
500
519
|
// Cancel any pending frame and clear the queue. A morph already in
|
|
@@ -751,6 +770,14 @@ class LiveSync {
|
|
|
751
770
|
|
|
752
771
|
const etag = typeof data.etag === 'string' && data.etag ? data.etag : null;
|
|
753
772
|
|
|
773
|
+
// On a host that stamps saves, every landed save is relayed with its stamp.
|
|
774
|
+
// A live-lane frame without one is an older client's preview of a save that
|
|
775
|
+
// may yet be refused; the stamped frame follows if it lands.
|
|
776
|
+
if (this.lane === 'live' && !etag && conditionalSaves()) {
|
|
777
|
+
this._log(`Dropping an unstamped live frame on a stamping host (seq=${seq})`);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
754
781
|
// The common case, and it costs nothing: a peer saved bytes this tab has
|
|
755
782
|
// already applied, so the frame's only news is the stamp. Taking it without
|
|
756
783
|
// a morph is exactly as safe as taking it after one, because the baseline
|
|
@@ -758,6 +785,10 @@ class LiveSync {
|
|
|
758
785
|
if (etag && html === this.lastHtml) {
|
|
759
786
|
this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
|
|
760
787
|
recordEtag(etag);
|
|
788
|
+
if (isSaveConflicted() && this.unresolvedConflicts.length === 0) {
|
|
789
|
+
conflictResolvedBySync(etag);
|
|
790
|
+
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
791
|
+
}
|
|
761
792
|
return;
|
|
762
793
|
}
|
|
763
794
|
|
|
@@ -785,15 +816,15 @@ class LiveSync {
|
|
|
785
816
|
*/
|
|
786
817
|
listenForSnapshots() {
|
|
787
818
|
this._snapshotHandler = (event) => {
|
|
819
|
+
// Only a save's own capture names what the next landed save stored, and
|
|
820
|
+
// when. A capture made for any other reason (the public captureForSave)
|
|
821
|
+
// must not replace the payload that save will relay, nor move its ticket.
|
|
822
|
+
if (!event.detail || event.detail.forSave !== true) return;
|
|
788
823
|
// Every save captures through this event, before its request goes out,
|
|
789
|
-
// so this is the moment the save's bytes were true of the document.
|
|
790
|
-
//
|
|
791
|
-
//
|
|
824
|
+
// so this is the moment the save's bytes were true of the document. A
|
|
825
|
+
// save made during a frame's await is still a save, and it is newer than
|
|
826
|
+
// that frame.
|
|
792
827
|
this._saveTicket = this._ticket();
|
|
793
|
-
if (this.isPaused) {
|
|
794
|
-
this._log('snapshot-ready received but isPaused, skipping');
|
|
795
|
-
return;
|
|
796
|
-
}
|
|
797
828
|
|
|
798
829
|
const { documentElement: clone } = event.detail;
|
|
799
830
|
if (!clone) return;
|
|
@@ -810,43 +841,33 @@ class LiveSync {
|
|
|
810
841
|
// content its stamp will describe. Held for _relayCommit, and overwritten
|
|
811
842
|
// by the next capture, so it always names the save currently in flight.
|
|
812
843
|
this._savedSnapshot = { html, identityMap };
|
|
813
|
-
|
|
814
|
-
this.sendUpdate(html, identityMap);
|
|
815
844
|
};
|
|
816
845
|
|
|
817
846
|
document.addEventListener('clay:snapshot-ready', this._snapshotHandler);
|
|
818
847
|
}
|
|
819
848
|
|
|
820
849
|
/**
|
|
821
|
-
* Send
|
|
850
|
+
* Send a landed save's HTML to the relay (single-flight).
|
|
822
851
|
*
|
|
823
852
|
* One POST on the wire at a time, with at most one newer payload queued; a
|
|
824
853
|
* fresher snapshot replaces the queued one, because peers only ever need the
|
|
825
854
|
* latest state. Two concurrent fetches could reach the server in either order,
|
|
826
855
|
* and last-write-wins then stored the OLDER snapshot for every peer.
|
|
827
856
|
*/
|
|
828
|
-
|
|
829
|
-
clearTimeout(this.debounceTimer);
|
|
830
|
-
|
|
831
|
-
this.debounceTimer = setTimeout(() => {
|
|
832
|
-
this._enqueueSend(html, identityMap);
|
|
833
|
-
}, this.debounceMs);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
_enqueueSend(html, identityMap) {
|
|
857
|
+
_enqueueSend(html, identityMap, etag = null) {
|
|
837
858
|
// Same one-deep queue the in-flight case uses, for the same reason: peers only
|
|
838
859
|
// ever need the newest state, so a fresher snapshot replaces the waiting one.
|
|
839
860
|
// Sending before the profile is known would have to guess an address.
|
|
840
861
|
if (!this._profile) {
|
|
841
|
-
this._queuedSend = { html, identityMap };
|
|
862
|
+
this._queuedSend = { html, identityMap, etag };
|
|
842
863
|
this._resolveProfile().then(() => this._flushQueuedSend());
|
|
843
864
|
return;
|
|
844
865
|
}
|
|
845
866
|
if (this._sendInFlight) {
|
|
846
|
-
this._queuedSend = { html, identityMap };
|
|
867
|
+
this._queuedSend = { html, identityMap, etag };
|
|
847
868
|
return;
|
|
848
869
|
}
|
|
849
|
-
this._postUpdate(html, identityMap);
|
|
870
|
+
this._postUpdate(html, identityMap, etag);
|
|
850
871
|
}
|
|
851
872
|
|
|
852
873
|
_flushQueuedSend() {
|
|
@@ -854,7 +875,7 @@ class LiveSync {
|
|
|
854
875
|
const queued = this._queuedSend;
|
|
855
876
|
if (!queued) return;
|
|
856
877
|
this._queuedSend = null;
|
|
857
|
-
this._postUpdate(queued.html, queued.identityMap);
|
|
878
|
+
this._postUpdate(queued.html, queued.identityMap, queued.etag);
|
|
858
879
|
}
|
|
859
880
|
|
|
860
881
|
/**
|
|
@@ -862,23 +883,14 @@ class LiveSync {
|
|
|
862
883
|
*
|
|
863
884
|
* Runs on clay:save-saved, by which point the save response has already been
|
|
864
885
|
* recorded, so `lastSeenEtag()` is the stamp for the bytes that just landed.
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
* already gone out. Re-sending those same bytes is not redundant, because the
|
|
870
|
-
* stamp is the new information and a stamp may never travel without the content
|
|
871
|
-
* it describes. A receiver whose baseline already matches takes the stamp and
|
|
872
|
-
* skips the morph.
|
|
886
|
+
* The content is `_savedSnapshot`, captured by that save's own snapshot-ready
|
|
887
|
+
* event, never `lastHtml`: a capture is not relayed until its save lands, so
|
|
888
|
+
* peers only ever see versions the host accepted, each descending from the one
|
|
889
|
+
* before. A host that returns no stamp still gets the relay, without one.
|
|
873
890
|
*/
|
|
874
891
|
_relayCommit() {
|
|
875
|
-
|
|
876
|
-
// that carried one, and a host that does not do conditional saves returns
|
|
877
|
-
// none, so this is the same test as "the host stamps what it stores" without
|
|
878
|
-
// a second flag that would have to be reached through discovery to exercise.
|
|
892
|
+
if (this.isDestroyed) return;
|
|
879
893
|
const etag = lastSeenEtag();
|
|
880
|
-
if (!etag) return;
|
|
881
|
-
if (this.isDestroyed || this.isPaused) return;
|
|
882
894
|
|
|
883
895
|
// The content this save stored, captured with it. Consumed rather than left
|
|
884
896
|
// behind, so a save-saved with no capture of its own can never reuse an
|
|
@@ -886,51 +898,18 @@ class LiveSync {
|
|
|
886
898
|
const pending = this._savedSnapshot;
|
|
887
899
|
this._savedSnapshot = null;
|
|
888
900
|
|
|
889
|
-
// No captured snapshot means nothing here knows which bytes this
|
|
890
|
-
//
|
|
891
|
-
// Staying silent costs the other editors one refusal they recover from;
|
|
892
|
-
// guessing costs somebody their work.
|
|
901
|
+
// No captured snapshot means nothing here knows which bytes this save
|
|
902
|
+
// stored, and §10 is explicit that a stamp must never travel on its own.
|
|
893
903
|
if (!pending || typeof pending.html !== 'string') return;
|
|
894
904
|
|
|
895
|
-
this.
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
/**
|
|
899
|
-
* Post a snapshot whose point is the stamp attached to it.
|
|
900
|
-
*
|
|
901
|
-
* Deliberately not `_postUpdate`: that one returns early when the html matches
|
|
902
|
-
* `lastHtml`, and the whole point here is that the stamp is the new information
|
|
903
|
-
* even when the bytes are not. It also does not touch `lastHtml` or the
|
|
904
|
-
* single-flight queue, because it must not displace a real snapshot waiting to
|
|
905
|
-
* go out. It carries the identityMap captured with these bytes, so a receiver
|
|
906
|
-
* that morphs on this frame pairs elements the same way it would on the
|
|
907
|
-
* ordinary relay of the same content.
|
|
908
|
-
*/
|
|
909
|
-
_postCommit(html, etag, identityMap) {
|
|
910
|
-
const profile = this._profile;
|
|
911
|
-
if (!profile) return;
|
|
912
|
-
fetch(new URL(profile.relayPath, window.location.origin).href, {
|
|
913
|
-
method: 'POST',
|
|
914
|
-
headers: {
|
|
915
|
-
'Content-Type': 'application/json',
|
|
916
|
-
[profile.documentHeader]: window.location.href,
|
|
917
|
-
},
|
|
918
|
-
body: JSON.stringify({
|
|
919
|
-
[profile.snapshotKey]: html,
|
|
920
|
-
sender: this.clientId,
|
|
921
|
-
identityMap,
|
|
922
|
-
etag
|
|
923
|
-
})
|
|
924
|
-
}).catch(err => {
|
|
925
|
-
// Losing this costs the other editors one spurious refusal on their next
|
|
926
|
-
// save, which they recover from. It is not worth surfacing as an error.
|
|
927
|
-
this._log('Commit relay failed: ' + (err && err.message));
|
|
928
|
-
});
|
|
905
|
+
this._enqueueSend(pending.html, pending.identityMap, etag || null);
|
|
929
906
|
}
|
|
930
907
|
|
|
931
|
-
_postUpdate(html, identityMap) {
|
|
932
|
-
// Skip
|
|
933
|
-
|
|
908
|
+
_postUpdate(html, identityMap, etag = null, attempt = 0) {
|
|
909
|
+
// Skip only an unstamped repeat. A landed save is news even when its bytes
|
|
910
|
+
// equal the last relay: "Keep mine" can restore exactly those bytes after
|
|
911
|
+
// the peers moved on, and they must hear it.
|
|
912
|
+
if (!etag && html === this.lastHtml) {
|
|
934
913
|
this._log('Skipping send - HTML unchanged');
|
|
935
914
|
return;
|
|
936
915
|
}
|
|
@@ -939,6 +918,7 @@ class LiveSync {
|
|
|
939
918
|
|
|
940
919
|
this._sendInFlight = true;
|
|
941
920
|
const gen = this._applyGen;
|
|
921
|
+
let retry = false;
|
|
942
922
|
|
|
943
923
|
// Absolute against the real origin, so a <base href> in the page cannot
|
|
944
924
|
// redirect the whole document to an origin the author picked.
|
|
@@ -952,7 +932,8 @@ class LiveSync {
|
|
|
952
932
|
body: JSON.stringify({
|
|
953
933
|
[profile.snapshotKey]: html,
|
|
954
934
|
sender: this.clientId,
|
|
955
|
-
identityMap: identityMap
|
|
935
|
+
identityMap: identityMap,
|
|
936
|
+
...(etag ? { etag } : {})
|
|
956
937
|
})
|
|
957
938
|
}).then(response => {
|
|
958
939
|
if (response.ok) {
|
|
@@ -968,15 +949,33 @@ class LiveSync {
|
|
|
968
949
|
}
|
|
969
950
|
} else {
|
|
970
951
|
console.warn('[LiveSync] Save returned status:', response.status);
|
|
952
|
+
retry = response.status >= 500 || response.status === 408 || response.status === 429;
|
|
971
953
|
}
|
|
972
954
|
}).catch(err => {
|
|
973
955
|
console.error('[LiveSync] Save failed:', err);
|
|
974
956
|
if (this.onError) this.onError(err);
|
|
957
|
+
retry = true;
|
|
975
958
|
}).finally(() => {
|
|
976
959
|
this._sendInFlight = false;
|
|
977
960
|
const queued = this._queuedSend;
|
|
978
961
|
this._queuedSend = null;
|
|
979
|
-
if (queued)
|
|
962
|
+
if (queued) {
|
|
963
|
+
this._postUpdate(queued.html, queued.identityMap, queued.etag);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
// The relay of a landed save is the only one peers get, so a transient
|
|
967
|
+
// failure is retried a few times. A newer save queued meanwhile replaces
|
|
968
|
+
// it: peers only need the latest.
|
|
969
|
+
if (!retry || !etag || attempt >= 3 || this.isDestroyed) return;
|
|
970
|
+
this._sendInFlight = true;
|
|
971
|
+
setTimeout(() => {
|
|
972
|
+
this._sendInFlight = false;
|
|
973
|
+
if (this.isDestroyed) return;
|
|
974
|
+
const newer = this._queuedSend;
|
|
975
|
+
this._queuedSend = null;
|
|
976
|
+
if (newer) this._postUpdate(newer.html, newer.identityMap, newer.etag);
|
|
977
|
+
else this._postUpdate(html, identityMap, etag, attempt + 1);
|
|
978
|
+
}, 500 * 2 ** attempt);
|
|
980
979
|
});
|
|
981
980
|
}
|
|
982
981
|
|
|
@@ -1445,6 +1444,7 @@ class LiveSync {
|
|
|
1445
1444
|
|
|
1446
1445
|
let diverged = false;
|
|
1447
1446
|
let typedDuringWait = false;
|
|
1447
|
+
let stamped = false;
|
|
1448
1448
|
try {
|
|
1449
1449
|
// Hold the whole frame only when this tab has unsaved edits and no
|
|
1450
1450
|
// baseline to merge them against (the first frame of a fresh
|
|
@@ -1522,7 +1522,10 @@ class LiveSync {
|
|
|
1522
1522
|
// above, and deliberately the same moment: the two claims a page makes
|
|
1523
1523
|
// when it adopts a stamp are "the host stores this version" and "I hold
|
|
1524
1524
|
// it", and only the second one is this tab's to make.
|
|
1525
|
-
if (typeof etag === 'string' && etag)
|
|
1525
|
+
if (typeof etag === 'string' && etag) {
|
|
1526
|
+
recordEtag(etag);
|
|
1527
|
+
stamped = true;
|
|
1528
|
+
}
|
|
1526
1529
|
|
|
1527
1530
|
// Cross-lane baseline: the DOM now holds this frame's content, but the
|
|
1528
1531
|
// DISK baseline (lastSavedContents) still describes pre-frame state. A
|
|
@@ -1593,9 +1596,12 @@ class LiveSync {
|
|
|
1593
1596
|
// edits + their frame) that exists only in this DOM. Push it out explicitly
|
|
1594
1597
|
// — the morph ran under Mutation.pause, so no autosave was triggered, and a
|
|
1595
1598
|
// pending autosave debounce may already have fired mid-flight. Runs after
|
|
1596
|
-
// isPaused is lifted so the save's
|
|
1599
|
+
// isPaused is lifted so the convergence save's relay reaches peers.
|
|
1597
1600
|
// Only where autosave is on: a manual-save page must never auto-write, so
|
|
1598
1601
|
// there the merge stays local, still dirty, until the person saves.
|
|
1602
|
+
// Not over a lost conflict: the hold and its bar stay up as the signal that
|
|
1603
|
+
// this tab lost text, until the person saves.
|
|
1604
|
+
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
1599
1605
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1600
1606
|
}
|
|
1601
1607
|
|
|
@@ -1626,6 +1632,7 @@ class LiveSync {
|
|
|
1626
1632
|
|
|
1627
1633
|
let diverged = false;
|
|
1628
1634
|
let typedDuringWait = false;
|
|
1635
|
+
let stamped = false;
|
|
1629
1636
|
try {
|
|
1630
1637
|
// Hold the whole frame only when this tab has unsaved edits and no
|
|
1631
1638
|
// baseline to merge them against. Lane-guarded like the peer path: a
|
|
@@ -1682,7 +1689,10 @@ class LiveSync {
|
|
|
1682
1689
|
// A frame with no stamp (an older host, or the content-less fetch fallback,
|
|
1683
1690
|
// which serves bytes nobody stamped) leaves this alone, and the listener in
|
|
1684
1691
|
// etag.js falls back to asking the host.
|
|
1685
|
-
if (typeof etag === 'string' && etag)
|
|
1692
|
+
if (typeof etag === 'string' && etag) {
|
|
1693
|
+
recordEtag(etag);
|
|
1694
|
+
stamped = true;
|
|
1695
|
+
}
|
|
1686
1696
|
|
|
1687
1697
|
if (
|
|
1688
1698
|
this.lane === 'live' &&
|
|
@@ -1733,6 +1743,9 @@ class LiveSync {
|
|
|
1733
1743
|
// (our edits + their bytes). The baseline was left pre-external, so the
|
|
1734
1744
|
// save sees both as changes and writes the merge back. Only where
|
|
1735
1745
|
// autosave is on, as in the peer lane.
|
|
1746
|
+
// Not over a lost conflict: the hold and its bar stay up as the signal that
|
|
1747
|
+
// this tab lost text, until the person saves.
|
|
1748
|
+
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
1736
1749
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1737
1750
|
}
|
|
1738
1751
|
|