@panphora/clayjs 1.5.1 → 1.5.3
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 +174 -95
- 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 +42 -3
- package/src/core/snapshot.js +1 -1
- package/src/lib/user-gesture.js +10 -0
- package/src/sync/live-sync.js +165 -101
package/dist/clay.standalone.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* clayjs 1.5.
|
|
1
|
+
/* clayjs 1.5.3 standalone build: every clayjs module in one file. https://clayjs.com/offline
|
|
2
2
|
Third-party code inside keeps its own license (Sortable MIT, Squire MIT, DOMPurify Apache-2.0 OR MPL-2.0, MicroModal MIT, parse5 MIT): https://clayjs.com/THIRD-PARTY-NOTICES.md */
|
|
3
3
|
(() => {
|
|
4
4
|
var __create = Object.create;
|
|
@@ -541,6 +541,7 @@
|
|
|
541
541
|
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
542
542
|
}
|
|
543
543
|
function markGestureTurn() {
|
|
544
|
+
anyGesture = true;
|
|
544
545
|
gestureTaskActive = true;
|
|
545
546
|
lastTrustedGestureTs = now();
|
|
546
547
|
setTimeout(() => {
|
|
@@ -565,6 +566,9 @@
|
|
|
565
566
|
function markUserDriven() {
|
|
566
567
|
userDrivenSinceLastSave = true;
|
|
567
568
|
}
|
|
569
|
+
function gestureSeen() {
|
|
570
|
+
return anyGesture;
|
|
571
|
+
}
|
|
568
572
|
function consumeUserDriven() {
|
|
569
573
|
const v2 = userDrivenSinceLastSave;
|
|
570
574
|
userDrivenSinceLastSave = false;
|
|
@@ -581,13 +585,14 @@
|
|
|
581
585
|
function clearExplicitSave() {
|
|
582
586
|
explicitSaveIntent = false;
|
|
583
587
|
}
|
|
584
|
-
var GESTURE_EVENTS, RECENT_GESTURE_MS, gestureTaskActive, lastTrustedGestureTs, userDrivenSinceLastSave, explicitSaveIntent, installed;
|
|
588
|
+
var GESTURE_EVENTS, RECENT_GESTURE_MS, gestureTaskActive, lastTrustedGestureTs, anyGesture, userDrivenSinceLastSave, explicitSaveIntent, installed;
|
|
585
589
|
var init_user_gesture = __esm({
|
|
586
590
|
"src/lib/user-gesture.js"() {
|
|
587
591
|
GESTURE_EVENTS = ["pointerdown", "pointerup", "click", "keydown", "beforeinput", "change", "submit", "paste", "drop", "cut"];
|
|
588
592
|
RECENT_GESTURE_MS = 500;
|
|
589
593
|
gestureTaskActive = false;
|
|
590
594
|
lastTrustedGestureTs = -Infinity;
|
|
595
|
+
anyGesture = false;
|
|
591
596
|
userDrivenSinceLastSave = false;
|
|
592
597
|
explicitSaveIntent = false;
|
|
593
598
|
installed = false;
|
|
@@ -1555,7 +1560,7 @@
|
|
|
1555
1560
|
const clone = captureSnapshot({ flushUndo });
|
|
1556
1561
|
if (emitForSync) {
|
|
1557
1562
|
document.dispatchEvent(new CustomEvent("clay:snapshot-ready", {
|
|
1558
|
-
detail: { documentElement: clone }
|
|
1563
|
+
detail: { documentElement: clone, forSave: true }
|
|
1559
1564
|
}));
|
|
1560
1565
|
}
|
|
1561
1566
|
runAuthoredHandlers(clone, "onbeforesave");
|
|
@@ -1898,7 +1903,7 @@
|
|
|
1898
1903
|
async function seedEtag({ fresh = false, clearIfMissing = fresh } = {}) {
|
|
1899
1904
|
const at3 = generation;
|
|
1900
1905
|
const meta = await hostMeta({ fresh });
|
|
1901
|
-
conditional = meta.extensions.includes("conditional");
|
|
1906
|
+
if (typeof meta.spec === "number") conditional = meta.extensions.includes("conditional");
|
|
1902
1907
|
if (generation !== at3) return lastSeen;
|
|
1903
1908
|
const seed = meta.document?.etag;
|
|
1904
1909
|
if (typeof seed === "string" && seed !== "") {
|
|
@@ -2005,6 +2010,10 @@
|
|
|
2005
2010
|
// A refusal that may be answering this tab's own timed-out write. Only the
|
|
2006
2011
|
// notice uses it, and only to word itself; nothing decides anything on it.
|
|
2007
2012
|
afterTimeout: conflicted && unknownAttempt !== null,
|
|
2013
|
+
// The stamp the host refused this save against: the version that beat it.
|
|
2014
|
+
// Live sync compares it with the frames it merges to know when the refusal
|
|
2015
|
+
// is answered.
|
|
2016
|
+
conflictEtag: conflicted ? err.etag ?? null : null,
|
|
2008
2017
|
etag: null
|
|
2009
2018
|
};
|
|
2010
2019
|
}
|
|
@@ -2377,6 +2386,20 @@
|
|
|
2377
2386
|
}
|
|
2378
2387
|
});
|
|
2379
2388
|
|
|
2389
|
+
// src/lib/autosave-state.js
|
|
2390
|
+
function setAutosaveActive(value) {
|
|
2391
|
+
active = !!value;
|
|
2392
|
+
}
|
|
2393
|
+
function autosaveActive() {
|
|
2394
|
+
return active;
|
|
2395
|
+
}
|
|
2396
|
+
var active;
|
|
2397
|
+
var init_autosave_state = __esm({
|
|
2398
|
+
"src/lib/autosave-state.js"() {
|
|
2399
|
+
active = false;
|
|
2400
|
+
}
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2380
2403
|
// src/lib/autosave-debug.js
|
|
2381
2404
|
function isDebugEnabled() {
|
|
2382
2405
|
try {
|
|
@@ -2479,6 +2502,8 @@
|
|
|
2479
2502
|
var save_exports = {};
|
|
2480
2503
|
__export(save_exports, {
|
|
2481
2504
|
addDocumentTransform: () => addDocumentTransform,
|
|
2505
|
+
baselineSettled: () => baselineSettled,
|
|
2506
|
+
conflictResolvedBySync: () => conflictResolvedBySync,
|
|
2482
2507
|
default: () => save_default,
|
|
2483
2508
|
getLastSavedBytes: () => getLastSavedBytes,
|
|
2484
2509
|
getLastSavedContents: () => getLastSavedContents,
|
|
@@ -2533,7 +2558,8 @@
|
|
|
2533
2558
|
autosaveMissed = false;
|
|
2534
2559
|
savePageThrottled();
|
|
2535
2560
|
}
|
|
2536
|
-
function holdForConflict() {
|
|
2561
|
+
function holdForConflict(etag) {
|
|
2562
|
+
conflictEtag = etag ?? null;
|
|
2537
2563
|
if (conflictHold) return;
|
|
2538
2564
|
conflictHold = true;
|
|
2539
2565
|
suspendAutosave();
|
|
@@ -2541,11 +2567,28 @@
|
|
|
2541
2567
|
function releaseConflictHold() {
|
|
2542
2568
|
if (!conflictHold) return;
|
|
2543
2569
|
conflictHold = false;
|
|
2570
|
+
conflictEtag = null;
|
|
2544
2571
|
resumeAutosave();
|
|
2545
2572
|
}
|
|
2546
2573
|
function isSaveConflicted() {
|
|
2547
2574
|
return conflictHold;
|
|
2548
2575
|
}
|
|
2576
|
+
function conflictResolvedBySync(etag) {
|
|
2577
|
+
if (!conflictHold) return;
|
|
2578
|
+
if (conflictEtag && etag !== conflictEtag) return;
|
|
2579
|
+
conflictHold = false;
|
|
2580
|
+
conflictEtag = null;
|
|
2581
|
+
const awaitingManualSave = !autosaveActive() && pageMaybeDirty();
|
|
2582
|
+
if (!awaitingManualSave) {
|
|
2583
|
+
if (document.documentElement.getAttribute("savestatus") === "conflict") {
|
|
2584
|
+
document.documentElement.setAttribute("savestatus", "saved");
|
|
2585
|
+
}
|
|
2586
|
+
document.dispatchEvent(new CustomEvent("clay:save-conflict-resolved", {
|
|
2587
|
+
detail: { timestamp: Date.now() }
|
|
2588
|
+
}));
|
|
2589
|
+
}
|
|
2590
|
+
resumeAutosave();
|
|
2591
|
+
}
|
|
2549
2592
|
function skipped_(msg) {
|
|
2550
2593
|
return { ok: false, msg, msgType: "skipped", code: null, etag: null };
|
|
2551
2594
|
}
|
|
@@ -2560,10 +2603,11 @@
|
|
|
2560
2603
|
logBaseline(label, `${lastSavedContents.length} chars`);
|
|
2561
2604
|
releaseConflictHold();
|
|
2562
2605
|
} else if (result2.msgType === "conflict") {
|
|
2563
|
-
holdForConflict();
|
|
2606
|
+
holdForConflict(result2.conflictEtag);
|
|
2564
2607
|
setSaveState("conflict", result2.msg, result2.msgType, {
|
|
2565
2608
|
changedBy: result2.changedBy ?? null,
|
|
2566
|
-
afterTimeout: result2.afterTimeout === true
|
|
2609
|
+
afterTimeout: result2.afterTimeout === true,
|
|
2610
|
+
etag: result2.conflictEtag ?? null
|
|
2567
2611
|
});
|
|
2568
2612
|
} else if (result2.msgType !== "skipped") {
|
|
2569
2613
|
if (!navigator.onLine) {
|
|
@@ -2716,6 +2760,9 @@
|
|
|
2716
2760
|
}
|
|
2717
2761
|
});
|
|
2718
2762
|
}
|
|
2763
|
+
function baselineSettled() {
|
|
2764
|
+
return baselineSettledAt;
|
|
2765
|
+
}
|
|
2719
2766
|
function initBaselineCapture() {
|
|
2720
2767
|
if (!isEditMode) return;
|
|
2721
2768
|
let userEdited = false;
|
|
@@ -2751,6 +2798,8 @@
|
|
|
2751
2798
|
}
|
|
2752
2799
|
baselineActive = false;
|
|
2753
2800
|
document.documentElement.setAttribute("savestatus", "saved");
|
|
2801
|
+
baselineSettledAt = true;
|
|
2802
|
+
document.dispatchEvent(new CustomEvent("clay:baseline-settled"));
|
|
2754
2803
|
};
|
|
2755
2804
|
unsubscribeMutation = mutation_default.onAnyChange(
|
|
2756
2805
|
{ debounce: SETTLE_MS, omitChangeDetails: true, require: "autosave" },
|
|
@@ -2818,7 +2867,7 @@
|
|
|
2818
2867
|
initSaveKeyboardShortcut();
|
|
2819
2868
|
initHyperclaySaveButton();
|
|
2820
2869
|
}
|
|
2821
|
-
var savingTimeout, unsavedChanges, lastSavedContents, lastSavedDirty, pendingSave, autosaveSuspended, autosaveMissed, conflictHold, lastSavedBytes, throttledSave, baselineContents, baselineActive, SETTLE_MS, MAX_SETTLE_MS, save_default;
|
|
2870
|
+
var savingTimeout, unsavedChanges, lastSavedContents, lastSavedDirty, pendingSave, autosaveSuspended, autosaveMissed, conflictHold, conflictEtag, lastSavedBytes, throttledSave, baselineContents, baselineActive, baselineSettledAt, SETTLE_MS, MAX_SETTLE_MS, save_default;
|
|
2822
2871
|
var init_save = __esm({
|
|
2823
2872
|
"src/core/save.js"() {
|
|
2824
2873
|
init_throttle();
|
|
@@ -2828,6 +2877,7 @@
|
|
|
2828
2877
|
init_snapshot();
|
|
2829
2878
|
init_etag();
|
|
2830
2879
|
init_dirty_gate();
|
|
2880
|
+
init_autosave_state();
|
|
2831
2881
|
init_root_attrs();
|
|
2832
2882
|
init_autosave_debug();
|
|
2833
2883
|
init_user_gesture();
|
|
@@ -2856,10 +2906,12 @@
|
|
|
2856
2906
|
autosaveSuspended = 0;
|
|
2857
2907
|
autosaveMissed = false;
|
|
2858
2908
|
conflictHold = false;
|
|
2909
|
+
conflictEtag = null;
|
|
2859
2910
|
lastSavedBytes = null;
|
|
2860
2911
|
throttledSave = throttle_default(savePage, 1200);
|
|
2861
2912
|
baselineContents = "";
|
|
2862
2913
|
baselineActive = true;
|
|
2914
|
+
baselineSettledAt = false;
|
|
2863
2915
|
SETTLE_MS = 500;
|
|
2864
2916
|
MAX_SETTLE_MS = 3e3;
|
|
2865
2917
|
if (document.readyState === "loading") {
|
|
@@ -3012,6 +3064,7 @@
|
|
|
3012
3064
|
if (!isEditMode) return;
|
|
3013
3065
|
document.addEventListener("clay:save-conflict", show);
|
|
3014
3066
|
document.addEventListener("clay:save-saved", hide);
|
|
3067
|
+
document.addEventListener("clay:save-conflict-resolved", hide);
|
|
3015
3068
|
document.addEventListener("keydown", onKeydown);
|
|
3016
3069
|
}
|
|
3017
3070
|
var BG2, INK2, EDGE2, WARN, FONT2, SOURCES, ARM_MS, root2, line, keep, drop, armTimer, armed, busy, still;
|
|
@@ -3369,20 +3422,6 @@
|
|
|
3369
3422
|
}
|
|
3370
3423
|
});
|
|
3371
3424
|
|
|
3372
|
-
// src/lib/autosave-state.js
|
|
3373
|
-
function setAutosaveActive(value) {
|
|
3374
|
-
active = !!value;
|
|
3375
|
-
}
|
|
3376
|
-
function autosaveActive() {
|
|
3377
|
-
return active;
|
|
3378
|
-
}
|
|
3379
|
-
var active;
|
|
3380
|
-
var init_autosave_state = __esm({
|
|
3381
|
-
"src/lib/autosave-state.js"() {
|
|
3382
|
-
active = false;
|
|
3383
|
-
}
|
|
3384
|
-
});
|
|
3385
|
-
|
|
3386
3425
|
// src/core/autosave.js
|
|
3387
3426
|
var autosave_exports = {};
|
|
3388
3427
|
__export(autosave_exports, {
|
|
@@ -12156,6 +12195,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12156
12195
|
init_host_meta();
|
|
12157
12196
|
init_etag();
|
|
12158
12197
|
init_dirty_gate();
|
|
12198
|
+
init_user_gesture();
|
|
12159
12199
|
init_stream();
|
|
12160
12200
|
init_save();
|
|
12161
12201
|
({ createIdentityStore } = HyperMorph);
|
|
@@ -12222,8 +12262,6 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12222
12262
|
this._conflictTickets = /* @__PURE__ */ new WeakMap();
|
|
12223
12263
|
this.clientId = this.generateClientId();
|
|
12224
12264
|
this.resumeId = null;
|
|
12225
|
-
this.debounceMs = 150;
|
|
12226
|
-
this.debounceTimer = null;
|
|
12227
12265
|
this._savedSnapshot = null;
|
|
12228
12266
|
this._sendInFlight = false;
|
|
12229
12267
|
this._queuedSend = null;
|
|
@@ -12248,6 +12286,8 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12248
12286
|
this._lastExternalSeq = 0;
|
|
12249
12287
|
this._saveEpoch = 0;
|
|
12250
12288
|
this._saveSavedHandler = null;
|
|
12289
|
+
this._saveConflictHandler = null;
|
|
12290
|
+
this._settledHandler = null;
|
|
12251
12291
|
this._lastIdentityMap = null;
|
|
12252
12292
|
this._applyGen = 0;
|
|
12253
12293
|
this._holdRetryPeer = null;
|
|
@@ -12295,6 +12335,18 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12295
12335
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
12296
12336
|
}
|
|
12297
12337
|
}
|
|
12338
|
+
/**
|
|
12339
|
+
* Seed both merge bases (the peer lane's and the disk lane's) with the page as
|
|
12340
|
+
* it stands, so a first frame that arrives after local edits merges instead of
|
|
12341
|
+
* holding. A page already dirty has no trustworthy base; it keeps null and holds.
|
|
12342
|
+
*/
|
|
12343
|
+
_seedBases() {
|
|
12344
|
+
if (pageMaybeDirty()) return;
|
|
12345
|
+
const clone = captureSnapshot({ flushUndo: false });
|
|
12346
|
+
this.lastHtml = serializeForSync(clone);
|
|
12347
|
+
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
12348
|
+
this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
|
|
12349
|
+
}
|
|
12298
12350
|
/**
|
|
12299
12351
|
* Start the LiveSync system
|
|
12300
12352
|
* Can be called after stop() to restart with a new file
|
|
@@ -12321,14 +12373,32 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12321
12373
|
this._lastExternalSeq = 0;
|
|
12322
12374
|
this._pendingExternal = null;
|
|
12323
12375
|
this.resumeId = this.generateResumeId();
|
|
12324
|
-
if (this.lane === "live"
|
|
12325
|
-
const clone = captureSnapshot({ flushUndo: false });
|
|
12326
|
-
this.lastHtml = serializeForSync(clone);
|
|
12327
|
-
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
12328
|
-
this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
|
|
12329
|
-
}
|
|
12376
|
+
if (this.lane === "live") this._seedBases();
|
|
12330
12377
|
console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
|
|
12331
12378
|
const gen = ++this._startGen;
|
|
12379
|
+
if (this.lane === "live" && !baselineSettled()) {
|
|
12380
|
+
const applyGen = this._applyGen;
|
|
12381
|
+
const saveEpoch = this._saveEpoch;
|
|
12382
|
+
const startHtml = this.lastHtml;
|
|
12383
|
+
const startDiskTicket = this._diskBaseTicket;
|
|
12384
|
+
const startDiskSeeded = this._diskBase !== null;
|
|
12385
|
+
this._settledHandler = () => {
|
|
12386
|
+
this._settledHandler = null;
|
|
12387
|
+
if (this.isDestroyed || this._startGen !== gen) return;
|
|
12388
|
+
if (gestureSeen()) return;
|
|
12389
|
+
if (pageMaybeDirty()) return;
|
|
12390
|
+
if (this._saveTicket !== 0) return;
|
|
12391
|
+
if (startHtml !== null && this._applyGen === applyGen && this._saveEpoch === saveEpoch && this.lastHtml === startHtml) {
|
|
12392
|
+
const clone = captureSnapshot({ flushUndo: false });
|
|
12393
|
+
this.lastHtml = serializeForSync(clone);
|
|
12394
|
+
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
12395
|
+
}
|
|
12396
|
+
if (startDiskSeeded && this._diskBaseTicket === startDiskTicket) {
|
|
12397
|
+
this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
|
|
12398
|
+
}
|
|
12399
|
+
};
|
|
12400
|
+
document.addEventListener("clay:baseline-settled", this._settledHandler, { once: true });
|
|
12401
|
+
}
|
|
12332
12402
|
this._ready = this._resolveProfile().then(() => {
|
|
12333
12403
|
if (this.isDestroyed || this._startGen !== gen) return;
|
|
12334
12404
|
this.connect();
|
|
@@ -12348,6 +12418,18 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12348
12418
|
this._relayCommit();
|
|
12349
12419
|
};
|
|
12350
12420
|
document.addEventListener("clay:save-saved", this._saveSavedHandler);
|
|
12421
|
+
this._saveConflictHandler = (event) => {
|
|
12422
|
+
const refusedBy = event.detail && event.detail.etag;
|
|
12423
|
+
if (!refusedBy) return;
|
|
12424
|
+
queueMicrotask(() => {
|
|
12425
|
+
if (this.isDestroyed) return;
|
|
12426
|
+
if (refusedBy !== lastSeenEtag()) return;
|
|
12427
|
+
if (this.unresolvedConflicts.length) return;
|
|
12428
|
+
conflictResolvedBySync(refusedBy);
|
|
12429
|
+
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
12430
|
+
});
|
|
12431
|
+
};
|
|
12432
|
+
document.addEventListener("clay:save-conflict", this._saveConflictHandler);
|
|
12351
12433
|
}
|
|
12352
12434
|
}
|
|
12353
12435
|
/**
|
|
@@ -12368,7 +12450,14 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12368
12450
|
document.removeEventListener("clay:save-saved", this._saveSavedHandler);
|
|
12369
12451
|
this._saveSavedHandler = null;
|
|
12370
12452
|
}
|
|
12371
|
-
|
|
12453
|
+
if (this._saveConflictHandler) {
|
|
12454
|
+
document.removeEventListener("clay:save-conflict", this._saveConflictHandler);
|
|
12455
|
+
this._saveConflictHandler = null;
|
|
12456
|
+
}
|
|
12457
|
+
if (this._settledHandler) {
|
|
12458
|
+
document.removeEventListener("clay:baseline-settled", this._settledHandler);
|
|
12459
|
+
this._settledHandler = null;
|
|
12460
|
+
}
|
|
12372
12461
|
this._queuedSend = null;
|
|
12373
12462
|
if (this._rafHandle != null) {
|
|
12374
12463
|
this._cancelFrame(this._rafHandle);
|
|
@@ -12548,9 +12637,17 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12548
12637
|
return;
|
|
12549
12638
|
}
|
|
12550
12639
|
const etag = typeof data.etag === "string" && data.etag ? data.etag : null;
|
|
12640
|
+
if (this.lane === "live" && !etag && conditionalSaves()) {
|
|
12641
|
+
this._log(`Dropping an unstamped live frame on a stamping host (seq=${seq})`);
|
|
12642
|
+
return;
|
|
12643
|
+
}
|
|
12551
12644
|
if (etag && html === this.lastHtml) {
|
|
12552
12645
|
this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
|
|
12553
12646
|
recordEtag(etag);
|
|
12647
|
+
if (isSaveConflicted() && this.unresolvedConflicts.length === 0) {
|
|
12648
|
+
conflictResolvedBySync(etag);
|
|
12649
|
+
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
12650
|
+
}
|
|
12554
12651
|
return;
|
|
12555
12652
|
}
|
|
12556
12653
|
this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
|
|
@@ -12573,115 +12670,71 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12573
12670
|
*/
|
|
12574
12671
|
listenForSnapshots() {
|
|
12575
12672
|
this._snapshotHandler = (event) => {
|
|
12673
|
+
if (!event.detail || event.detail.forSave !== true) return;
|
|
12576
12674
|
this._saveTicket = this._ticket();
|
|
12577
|
-
if (this.isPaused) {
|
|
12578
|
-
this._log("snapshot-ready received but isPaused, skipping");
|
|
12579
|
-
return;
|
|
12580
|
-
}
|
|
12581
12675
|
const { documentElement: clone } = event.detail;
|
|
12582
12676
|
if (!clone) return;
|
|
12583
12677
|
this._log("snapshot-ready received, preparing to send");
|
|
12584
12678
|
const html = serializeForSync(clone);
|
|
12585
12679
|
const identityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
12586
12680
|
this._savedSnapshot = { html, identityMap };
|
|
12587
|
-
this.sendUpdate(html, identityMap);
|
|
12588
12681
|
};
|
|
12589
12682
|
document.addEventListener("clay:snapshot-ready", this._snapshotHandler);
|
|
12590
12683
|
}
|
|
12591
12684
|
/**
|
|
12592
|
-
* Send
|
|
12685
|
+
* Send a landed save's HTML to the relay (single-flight).
|
|
12593
12686
|
*
|
|
12594
12687
|
* One POST on the wire at a time, with at most one newer payload queued; a
|
|
12595
12688
|
* fresher snapshot replaces the queued one, because peers only ever need the
|
|
12596
12689
|
* latest state. Two concurrent fetches could reach the server in either order,
|
|
12597
12690
|
* and last-write-wins then stored the OLDER snapshot for every peer.
|
|
12598
12691
|
*/
|
|
12599
|
-
|
|
12600
|
-
clearTimeout(this.debounceTimer);
|
|
12601
|
-
this.debounceTimer = setTimeout(() => {
|
|
12602
|
-
this._enqueueSend(html, identityMap);
|
|
12603
|
-
}, this.debounceMs);
|
|
12604
|
-
}
|
|
12605
|
-
_enqueueSend(html, identityMap) {
|
|
12692
|
+
_enqueueSend(html, identityMap, etag = null) {
|
|
12606
12693
|
if (!this._profile) {
|
|
12607
|
-
this._queuedSend = { html, identityMap };
|
|
12694
|
+
this._queuedSend = { html, identityMap, etag };
|
|
12608
12695
|
this._resolveProfile().then(() => this._flushQueuedSend());
|
|
12609
12696
|
return;
|
|
12610
12697
|
}
|
|
12611
12698
|
if (this._sendInFlight) {
|
|
12612
|
-
this._queuedSend = { html, identityMap };
|
|
12699
|
+
this._queuedSend = { html, identityMap, etag };
|
|
12613
12700
|
return;
|
|
12614
12701
|
}
|
|
12615
|
-
this._postUpdate(html, identityMap);
|
|
12702
|
+
this._postUpdate(html, identityMap, etag);
|
|
12616
12703
|
}
|
|
12617
12704
|
_flushQueuedSend() {
|
|
12618
12705
|
if (this.isDestroyed || this._sendInFlight) return;
|
|
12619
12706
|
const queued = this._queuedSend;
|
|
12620
12707
|
if (!queued) return;
|
|
12621
12708
|
this._queuedSend = null;
|
|
12622
|
-
this._postUpdate(queued.html, queued.identityMap);
|
|
12709
|
+
this._postUpdate(queued.html, queued.identityMap, queued.etag);
|
|
12623
12710
|
}
|
|
12624
12711
|
/**
|
|
12625
12712
|
* Tell the other editors what this tab's save stored, and under which stamp.
|
|
12626
12713
|
*
|
|
12627
12714
|
* Runs on clay:save-saved, by which point the save response has already been
|
|
12628
12715
|
* recorded, so `lastSeenEtag()` is the stamp for the bytes that just landed.
|
|
12629
|
-
*
|
|
12630
|
-
*
|
|
12631
|
-
*
|
|
12632
|
-
*
|
|
12633
|
-
* already gone out. Re-sending those same bytes is not redundant, because the
|
|
12634
|
-
* stamp is the new information and a stamp may never travel without the content
|
|
12635
|
-
* it describes. A receiver whose baseline already matches takes the stamp and
|
|
12636
|
-
* skips the morph.
|
|
12716
|
+
* The content is `_savedSnapshot`, captured by that save's own snapshot-ready
|
|
12717
|
+
* event, never `lastHtml`: a capture is not relayed until its save lands, so
|
|
12718
|
+
* peers only ever see versions the host accepted, each descending from the one
|
|
12719
|
+
* before. A host that returns no stamp still gets the relay, without one.
|
|
12637
12720
|
*/
|
|
12638
12721
|
_relayCommit() {
|
|
12722
|
+
if (this.isDestroyed) return;
|
|
12639
12723
|
const etag = lastSeenEtag();
|
|
12640
|
-
if (!etag) return;
|
|
12641
|
-
if (this.isDestroyed || this.isPaused) return;
|
|
12642
12724
|
const pending2 = this._savedSnapshot;
|
|
12643
12725
|
this._savedSnapshot = null;
|
|
12644
12726
|
if (!pending2 || typeof pending2.html !== "string") return;
|
|
12645
|
-
this.
|
|
12727
|
+
this._enqueueSend(pending2.html, pending2.identityMap, etag || null);
|
|
12646
12728
|
}
|
|
12647
|
-
|
|
12648
|
-
|
|
12649
|
-
*
|
|
12650
|
-
* Deliberately not `_postUpdate`: that one returns early when the html matches
|
|
12651
|
-
* `lastHtml`, and the whole point here is that the stamp is the new information
|
|
12652
|
-
* even when the bytes are not. It also does not touch `lastHtml` or the
|
|
12653
|
-
* single-flight queue, because it must not displace a real snapshot waiting to
|
|
12654
|
-
* go out. It carries the identityMap captured with these bytes, so a receiver
|
|
12655
|
-
* that morphs on this frame pairs elements the same way it would on the
|
|
12656
|
-
* ordinary relay of the same content.
|
|
12657
|
-
*/
|
|
12658
|
-
_postCommit(html, etag, identityMap) {
|
|
12659
|
-
const profile = this._profile;
|
|
12660
|
-
if (!profile) return;
|
|
12661
|
-
fetch(new URL(profile.relayPath, window.location.origin).href, {
|
|
12662
|
-
method: "POST",
|
|
12663
|
-
headers: {
|
|
12664
|
-
"Content-Type": "application/json",
|
|
12665
|
-
[profile.documentHeader]: window.location.href
|
|
12666
|
-
},
|
|
12667
|
-
body: JSON.stringify({
|
|
12668
|
-
[profile.snapshotKey]: html,
|
|
12669
|
-
sender: this.clientId,
|
|
12670
|
-
identityMap,
|
|
12671
|
-
etag
|
|
12672
|
-
})
|
|
12673
|
-
}).catch((err) => {
|
|
12674
|
-
this._log("Commit relay failed: " + (err && err.message));
|
|
12675
|
-
});
|
|
12676
|
-
}
|
|
12677
|
-
_postUpdate(html, identityMap) {
|
|
12678
|
-
if (html === this.lastHtml) {
|
|
12729
|
+
_postUpdate(html, identityMap, etag = null, attempt = 0) {
|
|
12730
|
+
if (!etag && html === this.lastHtml) {
|
|
12679
12731
|
this._log("Skipping send - HTML unchanged");
|
|
12680
12732
|
return;
|
|
12681
12733
|
}
|
|
12682
12734
|
this._log(`Sending update (HTML length: ${html.length}, lastHtml length: ${this.lastHtml?.length || 0})`);
|
|
12683
12735
|
this._sendInFlight = true;
|
|
12684
12736
|
const gen = this._applyGen;
|
|
12737
|
+
let retry = false;
|
|
12685
12738
|
const profile = this._profile || WIRE_PROFILES.legacy;
|
|
12686
12739
|
fetch(new URL(profile.relayPath, window.location.origin).href, {
|
|
12687
12740
|
method: "POST",
|
|
@@ -12692,7 +12745,8 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12692
12745
|
body: JSON.stringify({
|
|
12693
12746
|
[profile.snapshotKey]: html,
|
|
12694
12747
|
sender: this.clientId,
|
|
12695
|
-
identityMap
|
|
12748
|
+
identityMap,
|
|
12749
|
+
...etag ? { etag } : {}
|
|
12696
12750
|
})
|
|
12697
12751
|
}).then((response) => {
|
|
12698
12752
|
if (response.ok) {
|
|
@@ -12704,15 +12758,30 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12704
12758
|
}
|
|
12705
12759
|
} else {
|
|
12706
12760
|
console.warn("[LiveSync] Save returned status:", response.status);
|
|
12761
|
+
retry = response.status >= 500 || response.status === 408 || response.status === 429;
|
|
12707
12762
|
}
|
|
12708
12763
|
}).catch((err) => {
|
|
12709
12764
|
console.error("[LiveSync] Save failed:", err);
|
|
12710
12765
|
if (this.onError) this.onError(err);
|
|
12766
|
+
retry = true;
|
|
12711
12767
|
}).finally(() => {
|
|
12712
12768
|
this._sendInFlight = false;
|
|
12713
12769
|
const queued = this._queuedSend;
|
|
12714
12770
|
this._queuedSend = null;
|
|
12715
|
-
if (queued)
|
|
12771
|
+
if (queued) {
|
|
12772
|
+
this._postUpdate(queued.html, queued.identityMap, queued.etag);
|
|
12773
|
+
return;
|
|
12774
|
+
}
|
|
12775
|
+
if (!retry || !etag || attempt >= 3 || this.isDestroyed) return;
|
|
12776
|
+
this._sendInFlight = true;
|
|
12777
|
+
setTimeout(() => {
|
|
12778
|
+
this._sendInFlight = false;
|
|
12779
|
+
if (this.isDestroyed) return;
|
|
12780
|
+
const newer = this._queuedSend;
|
|
12781
|
+
this._queuedSend = null;
|
|
12782
|
+
if (newer) this._postUpdate(newer.html, newer.identityMap, newer.etag);
|
|
12783
|
+
else this._postUpdate(html, identityMap, etag, attempt + 1);
|
|
12784
|
+
}, 500 * 2 ** attempt);
|
|
12716
12785
|
});
|
|
12717
12786
|
}
|
|
12718
12787
|
/**
|
|
@@ -13070,6 +13139,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13070
13139
|
pauseGate();
|
|
13071
13140
|
let diverged = false;
|
|
13072
13141
|
let typedDuringWait = false;
|
|
13142
|
+
let stamped = false;
|
|
13073
13143
|
try {
|
|
13074
13144
|
if (this.lane === "live" && pageMaybeDirty() && this.lastHtml === null) {
|
|
13075
13145
|
console.log("[LiveSync] Holding incoming update: unsaved local edits and no baseline to merge against");
|
|
@@ -13103,7 +13173,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13103
13173
|
this.lastHtml = html;
|
|
13104
13174
|
this._lastIdentityMap = identityMap && typeof identityMap === "object" && !Array.isArray(identityMap) ? identityMap : null;
|
|
13105
13175
|
this._applyGen++;
|
|
13106
|
-
if (typeof etag === "string" && etag)
|
|
13176
|
+
if (typeof etag === "string" && etag) {
|
|
13177
|
+
recordEtag(etag);
|
|
13178
|
+
stamped = true;
|
|
13179
|
+
}
|
|
13107
13180
|
if (this.lane === "live" && !diverged && !pageMaybeDirty() && this.unresolvedConflicts.length === 0) {
|
|
13108
13181
|
const { forSave, forComparison, forDirty } = captureForSaveAndComparison({
|
|
13109
13182
|
emitForSync: false,
|
|
@@ -13122,6 +13195,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13122
13195
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
13123
13196
|
this.isPaused = false;
|
|
13124
13197
|
}
|
|
13198
|
+
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
13125
13199
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
13126
13200
|
}
|
|
13127
13201
|
/**
|
|
@@ -13148,6 +13222,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13148
13222
|
pauseGate();
|
|
13149
13223
|
let diverged = false;
|
|
13150
13224
|
let typedDuringWait = false;
|
|
13225
|
+
let stamped = false;
|
|
13151
13226
|
try {
|
|
13152
13227
|
if (this.lane === "live" && pageMaybeDirty() && this._diskBase === null) {
|
|
13153
13228
|
console.log("[LiveSync] Holding external change: unsaved local edits and no baseline to merge against");
|
|
@@ -13173,7 +13248,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13173
13248
|
typedDuringWait = typed;
|
|
13174
13249
|
this._setDiskBase(html, ticket);
|
|
13175
13250
|
window.scrollTo(scrollX, scrollY);
|
|
13176
|
-
if (typeof etag === "string" && etag)
|
|
13251
|
+
if (typeof etag === "string" && etag) {
|
|
13252
|
+
recordEtag(etag);
|
|
13253
|
+
stamped = true;
|
|
13254
|
+
}
|
|
13177
13255
|
if (this.lane === "live" && !diverged && !pageMaybeDirty() && this.unresolvedConflicts.length === 0) {
|
|
13178
13256
|
setLastSavedBaselines(...pairedBaseline());
|
|
13179
13257
|
setUnsavedChanges(false);
|
|
@@ -13201,6 +13279,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13201
13279
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
13202
13280
|
this.isPaused = false;
|
|
13203
13281
|
}
|
|
13282
|
+
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
13204
13283
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
13205
13284
|
}
|
|
13206
13285
|
/**
|
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) {
|
|
@@ -540,6 +572,11 @@ let baselineContents = '';
|
|
|
540
572
|
// The baseline veto only guards the load-time settle window; captureBaseline
|
|
541
573
|
// disarms it. See the comment there.
|
|
542
574
|
let baselineActive = true;
|
|
575
|
+
// True once the load-time settle has run. Live sync refreshes an untouched tab's
|
|
576
|
+
// merge bases at this moment: modules that build DOM at boot (an editor mounting
|
|
577
|
+
// from an editmode:resource script) are in the page by now.
|
|
578
|
+
let baselineSettledAt = false;
|
|
579
|
+
export function baselineSettled() { return baselineSettledAt; }
|
|
543
580
|
|
|
544
581
|
// ============================================
|
|
545
582
|
// BASELINE CAPTURE (Settled Signal)
|
|
@@ -626,6 +663,8 @@ function initBaselineCapture() {
|
|
|
626
663
|
baselineActive = false;
|
|
627
664
|
|
|
628
665
|
document.documentElement.setAttribute('savestatus', 'saved');
|
|
666
|
+
baselineSettledAt = true;
|
|
667
|
+
document.dispatchEvent(new CustomEvent('clay:baseline-settled'));
|
|
629
668
|
};
|
|
630
669
|
|
|
631
670
|
// Start settle observer - fires when no mutations for SETTLE_MS.
|
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/lib/user-gesture.js
CHANGED
|
@@ -44,6 +44,9 @@ const RECENT_GESTURE_MS = 500;
|
|
|
44
44
|
|
|
45
45
|
let gestureTaskActive = false;
|
|
46
46
|
let lastTrustedGestureTs = -Infinity;
|
|
47
|
+
// Set by the first trusted gesture and never cleared. Live sync reads it at the
|
|
48
|
+
// load-time settle: a page nobody has touched yet holds no one's edit.
|
|
49
|
+
let anyGesture = false;
|
|
47
50
|
let userDrivenSinceLastSave = false;
|
|
48
51
|
let explicitSaveIntent = false;
|
|
49
52
|
let installed = false;
|
|
@@ -55,6 +58,7 @@ function now() {
|
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
function markGestureTurn() {
|
|
61
|
+
anyGesture = true;
|
|
58
62
|
gestureTaskActive = true;
|
|
59
63
|
lastTrustedGestureTs = now();
|
|
60
64
|
// Clear the same-turn flag on the next macrotask. The MutationObserver
|
|
@@ -100,6 +104,11 @@ export function markUserDriven() {
|
|
|
100
104
|
userDrivenSinceLastSave = true;
|
|
101
105
|
}
|
|
102
106
|
|
|
107
|
+
/** True once any trusted gesture has reached the page. */
|
|
108
|
+
export function gestureSeen() {
|
|
109
|
+
return anyGesture;
|
|
110
|
+
}
|
|
111
|
+
|
|
103
112
|
/**
|
|
104
113
|
* Read-and-reset the accumulated bit. Called at the ACTUAL save send (not on a
|
|
105
114
|
* save that never ships), so it survives the autosave debounce and coalescing.
|
|
@@ -144,6 +153,7 @@ export function _resetUserGesture() {
|
|
|
144
153
|
gestureTaskActive = false;
|
|
145
154
|
lastTrustedGestureTs = -Infinity;
|
|
146
155
|
userDrivenSinceLastSave = false;
|
|
156
|
+
anyGesture = false;
|
|
147
157
|
explicitSaveIntent = false;
|
|
148
158
|
}
|
|
149
159
|
|
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,8 +55,9 @@ 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
|
+
import { gestureSeen } from '../lib/user-gesture.js';
|
|
60
61
|
import { SyncStream } from './stream.js';
|
|
61
62
|
|
|
62
63
|
// What a live-sync merge never reads or touches on any side: editor chrome,
|
|
@@ -144,7 +145,7 @@ function pairedBaseline() {
|
|
|
144
145
|
const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
|
|
145
146
|
return [forComparison, forDirty];
|
|
146
147
|
}
|
|
147
|
-
import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes } from '../core/save.js';
|
|
148
|
+
import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted, baselineSettled } from '../core/save.js';
|
|
148
149
|
|
|
149
150
|
/**
|
|
150
151
|
* The two live-sync wires, and the rule for choosing between them.
|
|
@@ -227,15 +228,12 @@ class LiveSync {
|
|
|
227
228
|
// durable per-tab identity used for echo suppression.
|
|
228
229
|
this.resumeId = null;
|
|
229
230
|
|
|
230
|
-
this.debounceMs = 150;
|
|
231
|
-
this.debounceTimer = null;
|
|
232
|
-
|
|
233
231
|
// The sync serialization of the clone the CURRENT save was captured from,
|
|
234
232
|
// held so the commit relay can pair the host's stamp with the content that
|
|
235
233
|
// stamp actually describes. Set at snapshot-ready, which fires before the
|
|
236
234
|
// save POST goes out, and consumed by _relayCommit when the response lands.
|
|
237
235
|
// Never reconstructed from lastHtml: that is the last relay that COMPLETED,
|
|
238
|
-
// which lags the save whenever
|
|
236
|
+
// which lags the save whenever an earlier relay is still in flight, and
|
|
239
237
|
// pairing a fresh stamp with older bytes is the one thing spec §10 forbids.
|
|
240
238
|
this._savedSnapshot = null;
|
|
241
239
|
this._sendInFlight = false;
|
|
@@ -305,6 +303,8 @@ class LiveSync {
|
|
|
305
303
|
// equal our state.
|
|
306
304
|
this._saveEpoch = 0;
|
|
307
305
|
this._saveSavedHandler = null;
|
|
306
|
+
this._saveConflictHandler = null;
|
|
307
|
+
this._settledHandler = null;
|
|
308
308
|
|
|
309
309
|
// The identityMap that came with lastHtml, so the peer-lane dirty diff
|
|
310
310
|
// can resolve synthetic identities on its base tree.
|
|
@@ -382,6 +382,19 @@ class LiveSync {
|
|
|
382
382
|
}
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Seed both merge bases (the peer lane's and the disk lane's) with the page as
|
|
387
|
+
* it stands, so a first frame that arrives after local edits merges instead of
|
|
388
|
+
* holding. A page already dirty has no trustworthy base; it keeps null and holds.
|
|
389
|
+
*/
|
|
390
|
+
_seedBases() {
|
|
391
|
+
if (pageMaybeDirty()) return;
|
|
392
|
+
const clone = captureSnapshot({ flushUndo: false });
|
|
393
|
+
this.lastHtml = serializeForSync(clone);
|
|
394
|
+
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
395
|
+
this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
|
|
396
|
+
}
|
|
397
|
+
|
|
385
398
|
/**
|
|
386
399
|
* Start the LiveSync system
|
|
387
400
|
* Can be called after stop() to restart with a new file
|
|
@@ -419,22 +432,53 @@ class LiveSync {
|
|
|
419
432
|
this._pendingExternal = null;
|
|
420
433
|
this.resumeId = this.generateResumeId();
|
|
421
434
|
|
|
422
|
-
// Seed both merge bases
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
if (this.lane === 'live'
|
|
427
|
-
const clone = captureSnapshot({ flushUndo: false });
|
|
428
|
-
this.lastHtml = serializeForSync(clone);
|
|
429
|
-
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
430
|
-
this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
|
|
431
|
-
}
|
|
435
|
+
// Seed both merge bases now, so a first frame that arrives after local edits
|
|
436
|
+
// merges instead of holding. A page still booting is refreshed at the settle
|
|
437
|
+
// below: a base taken before a module built its DOM makes the first dirty merge
|
|
438
|
+
// see both sides add that DOM, and keep it twice.
|
|
439
|
+
if (this.lane === 'live') this._seedBases();
|
|
432
440
|
|
|
433
441
|
console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
|
|
434
442
|
// One discovery request stands between here and the stream. It is memoized and
|
|
435
443
|
// bounded, and it does not gate ordinary saves — /_/save is a separate lane that
|
|
436
444
|
// never moved. Snapshots produced during the window are queued, not dropped.
|
|
437
445
|
const gen = ++this._startGen;
|
|
446
|
+
if (this.lane === 'live' && !baselineSettled()) {
|
|
447
|
+
const applyGen = this._applyGen;
|
|
448
|
+
const saveEpoch = this._saveEpoch;
|
|
449
|
+
const startHtml = this.lastHtml;
|
|
450
|
+
const startDiskTicket = this._diskBaseTicket;
|
|
451
|
+
const startDiskSeeded = this._diskBase !== null;
|
|
452
|
+
this._settledHandler = () => {
|
|
453
|
+
this._settledHandler = null;
|
|
454
|
+
if (this.isDestroyed || this._startGen !== gen) return;
|
|
455
|
+
// A person has touched the page: whatever they did (a drag, a button whose
|
|
456
|
+
// handler edits a turn later) may be in it and not yet saved. Keep the start
|
|
457
|
+
// seed, as 1.5.2 did; only an untouched tab gets the settled page as its base.
|
|
458
|
+
if (gestureSeen()) return;
|
|
459
|
+
// The settle clears the gate only when it saw no user edit, so a dirty page
|
|
460
|
+
// here holds work the base must not absorb: keep the start seed.
|
|
461
|
+
if (pageMaybeDirty()) return;
|
|
462
|
+
// A save this tab sent is still unconfirmed: what it carries is not yet
|
|
463
|
+
// common history, and a refused save must merge against the older base.
|
|
464
|
+
if (this._saveTicket !== 0) return;
|
|
465
|
+
// Refresh each lane only while it still holds what start left. A frame, a
|
|
466
|
+
// landed relay, an own save or a disk frame since then gave it a newer base
|
|
467
|
+
// (an own save counts even when its relay failed and lastHtml never moved).
|
|
468
|
+
// Only replace a seed start took, never fill a base start left empty: a page
|
|
469
|
+
// dirty at start held its frames in 1.5.2, and its own scripts may still be
|
|
470
|
+
// rewriting content the merge cannot reach.
|
|
471
|
+
if (startHtml !== null && this._applyGen === applyGen && this._saveEpoch === saveEpoch && this.lastHtml === startHtml) {
|
|
472
|
+
const clone = captureSnapshot({ flushUndo: false });
|
|
473
|
+
this.lastHtml = serializeForSync(clone);
|
|
474
|
+
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
475
|
+
}
|
|
476
|
+
if (startDiskSeeded && this._diskBaseTicket === startDiskTicket) {
|
|
477
|
+
this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
document.addEventListener('clay:baseline-settled', this._settledHandler, { once: true });
|
|
481
|
+
}
|
|
438
482
|
// Exposed so a caller (and the tests) can await the point where the stream is
|
|
439
483
|
// actually open, rather than counting microtasks behind the discovery request.
|
|
440
484
|
this._ready = this._resolveProfile().then(() => {
|
|
@@ -467,6 +511,23 @@ class LiveSync {
|
|
|
467
511
|
this._relayCommit();
|
|
468
512
|
};
|
|
469
513
|
document.addEventListener('clay:save-saved', this._saveSavedHandler);
|
|
514
|
+
// A 412 can arrive after this tab already merged the version that beat it:
|
|
515
|
+
// the winner's relay overtook the refusal. The refusal then names a stamp
|
|
516
|
+
// this tab already holds, and nothing else would ever release the hold.
|
|
517
|
+
// Deferred a microtask so the conflict notice's own listener has shown the
|
|
518
|
+
// bar before this hides it.
|
|
519
|
+
this._saveConflictHandler = (event) => {
|
|
520
|
+
const refusedBy = event.detail && event.detail.etag;
|
|
521
|
+
if (!refusedBy) return;
|
|
522
|
+
queueMicrotask(() => {
|
|
523
|
+
if (this.isDestroyed) return;
|
|
524
|
+
if (refusedBy !== lastSeenEtag()) return;
|
|
525
|
+
if (this.unresolvedConflicts.length) return;
|
|
526
|
+
conflictResolvedBySync(refusedBy);
|
|
527
|
+
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
528
|
+
});
|
|
529
|
+
};
|
|
530
|
+
document.addEventListener('clay:save-conflict', this._saveConflictHandler);
|
|
470
531
|
}
|
|
471
532
|
}
|
|
472
533
|
|
|
@@ -494,7 +555,16 @@ class LiveSync {
|
|
|
494
555
|
this._saveSavedHandler = null;
|
|
495
556
|
}
|
|
496
557
|
|
|
497
|
-
|
|
558
|
+
if (this._saveConflictHandler) {
|
|
559
|
+
document.removeEventListener('clay:save-conflict', this._saveConflictHandler);
|
|
560
|
+
this._saveConflictHandler = null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (this._settledHandler) {
|
|
564
|
+
document.removeEventListener('clay:baseline-settled', this._settledHandler);
|
|
565
|
+
this._settledHandler = null;
|
|
566
|
+
}
|
|
567
|
+
|
|
498
568
|
this._queuedSend = null;
|
|
499
569
|
|
|
500
570
|
// Cancel any pending frame and clear the queue. A morph already in
|
|
@@ -751,6 +821,14 @@ class LiveSync {
|
|
|
751
821
|
|
|
752
822
|
const etag = typeof data.etag === 'string' && data.etag ? data.etag : null;
|
|
753
823
|
|
|
824
|
+
// On a host that stamps saves, every landed save is relayed with its stamp.
|
|
825
|
+
// A live-lane frame without one is an older client's preview of a save that
|
|
826
|
+
// may yet be refused; the stamped frame follows if it lands.
|
|
827
|
+
if (this.lane === 'live' && !etag && conditionalSaves()) {
|
|
828
|
+
this._log(`Dropping an unstamped live frame on a stamping host (seq=${seq})`);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
754
832
|
// The common case, and it costs nothing: a peer saved bytes this tab has
|
|
755
833
|
// already applied, so the frame's only news is the stamp. Taking it without
|
|
756
834
|
// a morph is exactly as safe as taking it after one, because the baseline
|
|
@@ -758,6 +836,10 @@ class LiveSync {
|
|
|
758
836
|
if (etag && html === this.lastHtml) {
|
|
759
837
|
this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
|
|
760
838
|
recordEtag(etag);
|
|
839
|
+
if (isSaveConflicted() && this.unresolvedConflicts.length === 0) {
|
|
840
|
+
conflictResolvedBySync(etag);
|
|
841
|
+
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
842
|
+
}
|
|
761
843
|
return;
|
|
762
844
|
}
|
|
763
845
|
|
|
@@ -785,15 +867,15 @@ class LiveSync {
|
|
|
785
867
|
*/
|
|
786
868
|
listenForSnapshots() {
|
|
787
869
|
this._snapshotHandler = (event) => {
|
|
870
|
+
// Only a save's own capture names what the next landed save stored, and
|
|
871
|
+
// when. A capture made for any other reason (the public captureForSave)
|
|
872
|
+
// must not replace the payload that save will relay, nor move its ticket.
|
|
873
|
+
if (!event.detail || event.detail.forSave !== true) return;
|
|
788
874
|
// 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
|
-
//
|
|
875
|
+
// so this is the moment the save's bytes were true of the document. A
|
|
876
|
+
// save made during a frame's await is still a save, and it is newer than
|
|
877
|
+
// that frame.
|
|
792
878
|
this._saveTicket = this._ticket();
|
|
793
|
-
if (this.isPaused) {
|
|
794
|
-
this._log('snapshot-ready received but isPaused, skipping');
|
|
795
|
-
return;
|
|
796
|
-
}
|
|
797
879
|
|
|
798
880
|
const { documentElement: clone } = event.detail;
|
|
799
881
|
if (!clone) return;
|
|
@@ -810,43 +892,33 @@ class LiveSync {
|
|
|
810
892
|
// content its stamp will describe. Held for _relayCommit, and overwritten
|
|
811
893
|
// by the next capture, so it always names the save currently in flight.
|
|
812
894
|
this._savedSnapshot = { html, identityMap };
|
|
813
|
-
|
|
814
|
-
this.sendUpdate(html, identityMap);
|
|
815
895
|
};
|
|
816
896
|
|
|
817
897
|
document.addEventListener('clay:snapshot-ready', this._snapshotHandler);
|
|
818
898
|
}
|
|
819
899
|
|
|
820
900
|
/**
|
|
821
|
-
* Send
|
|
901
|
+
* Send a landed save's HTML to the relay (single-flight).
|
|
822
902
|
*
|
|
823
903
|
* One POST on the wire at a time, with at most one newer payload queued; a
|
|
824
904
|
* fresher snapshot replaces the queued one, because peers only ever need the
|
|
825
905
|
* latest state. Two concurrent fetches could reach the server in either order,
|
|
826
906
|
* and last-write-wins then stored the OLDER snapshot for every peer.
|
|
827
907
|
*/
|
|
828
|
-
|
|
829
|
-
clearTimeout(this.debounceTimer);
|
|
830
|
-
|
|
831
|
-
this.debounceTimer = setTimeout(() => {
|
|
832
|
-
this._enqueueSend(html, identityMap);
|
|
833
|
-
}, this.debounceMs);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
_enqueueSend(html, identityMap) {
|
|
908
|
+
_enqueueSend(html, identityMap, etag = null) {
|
|
837
909
|
// Same one-deep queue the in-flight case uses, for the same reason: peers only
|
|
838
910
|
// ever need the newest state, so a fresher snapshot replaces the waiting one.
|
|
839
911
|
// Sending before the profile is known would have to guess an address.
|
|
840
912
|
if (!this._profile) {
|
|
841
|
-
this._queuedSend = { html, identityMap };
|
|
913
|
+
this._queuedSend = { html, identityMap, etag };
|
|
842
914
|
this._resolveProfile().then(() => this._flushQueuedSend());
|
|
843
915
|
return;
|
|
844
916
|
}
|
|
845
917
|
if (this._sendInFlight) {
|
|
846
|
-
this._queuedSend = { html, identityMap };
|
|
918
|
+
this._queuedSend = { html, identityMap, etag };
|
|
847
919
|
return;
|
|
848
920
|
}
|
|
849
|
-
this._postUpdate(html, identityMap);
|
|
921
|
+
this._postUpdate(html, identityMap, etag);
|
|
850
922
|
}
|
|
851
923
|
|
|
852
924
|
_flushQueuedSend() {
|
|
@@ -854,7 +926,7 @@ class LiveSync {
|
|
|
854
926
|
const queued = this._queuedSend;
|
|
855
927
|
if (!queued) return;
|
|
856
928
|
this._queuedSend = null;
|
|
857
|
-
this._postUpdate(queued.html, queued.identityMap);
|
|
929
|
+
this._postUpdate(queued.html, queued.identityMap, queued.etag);
|
|
858
930
|
}
|
|
859
931
|
|
|
860
932
|
/**
|
|
@@ -862,23 +934,14 @@ class LiveSync {
|
|
|
862
934
|
*
|
|
863
935
|
* Runs on clay:save-saved, by which point the save response has already been
|
|
864
936
|
* 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.
|
|
937
|
+
* The content is `_savedSnapshot`, captured by that save's own snapshot-ready
|
|
938
|
+
* event, never `lastHtml`: a capture is not relayed until its save lands, so
|
|
939
|
+
* peers only ever see versions the host accepted, each descending from the one
|
|
940
|
+
* before. A host that returns no stamp still gets the relay, without one.
|
|
873
941
|
*/
|
|
874
942
|
_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.
|
|
943
|
+
if (this.isDestroyed) return;
|
|
879
944
|
const etag = lastSeenEtag();
|
|
880
|
-
if (!etag) return;
|
|
881
|
-
if (this.isDestroyed || this.isPaused) return;
|
|
882
945
|
|
|
883
946
|
// The content this save stored, captured with it. Consumed rather than left
|
|
884
947
|
// behind, so a save-saved with no capture of its own can never reuse an
|
|
@@ -886,51 +949,18 @@ class LiveSync {
|
|
|
886
949
|
const pending = this._savedSnapshot;
|
|
887
950
|
this._savedSnapshot = null;
|
|
888
951
|
|
|
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.
|
|
952
|
+
// No captured snapshot means nothing here knows which bytes this save
|
|
953
|
+
// stored, and §10 is explicit that a stamp must never travel on its own.
|
|
893
954
|
if (!pending || typeof pending.html !== 'string') return;
|
|
894
955
|
|
|
895
|
-
this.
|
|
956
|
+
this._enqueueSend(pending.html, pending.identityMap, etag || null);
|
|
896
957
|
}
|
|
897
958
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
});
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
_postUpdate(html, identityMap) {
|
|
932
|
-
// Skip if unchanged
|
|
933
|
-
if (html === this.lastHtml) {
|
|
959
|
+
_postUpdate(html, identityMap, etag = null, attempt = 0) {
|
|
960
|
+
// Skip only an unstamped repeat. A landed save is news even when its bytes
|
|
961
|
+
// equal the last relay: "Keep mine" can restore exactly those bytes after
|
|
962
|
+
// the peers moved on, and they must hear it.
|
|
963
|
+
if (!etag && html === this.lastHtml) {
|
|
934
964
|
this._log('Skipping send - HTML unchanged');
|
|
935
965
|
return;
|
|
936
966
|
}
|
|
@@ -939,6 +969,7 @@ class LiveSync {
|
|
|
939
969
|
|
|
940
970
|
this._sendInFlight = true;
|
|
941
971
|
const gen = this._applyGen;
|
|
972
|
+
let retry = false;
|
|
942
973
|
|
|
943
974
|
// Absolute against the real origin, so a <base href> in the page cannot
|
|
944
975
|
// redirect the whole document to an origin the author picked.
|
|
@@ -952,7 +983,8 @@ class LiveSync {
|
|
|
952
983
|
body: JSON.stringify({
|
|
953
984
|
[profile.snapshotKey]: html,
|
|
954
985
|
sender: this.clientId,
|
|
955
|
-
identityMap: identityMap
|
|
986
|
+
identityMap: identityMap,
|
|
987
|
+
...(etag ? { etag } : {})
|
|
956
988
|
})
|
|
957
989
|
}).then(response => {
|
|
958
990
|
if (response.ok) {
|
|
@@ -968,15 +1000,33 @@ class LiveSync {
|
|
|
968
1000
|
}
|
|
969
1001
|
} else {
|
|
970
1002
|
console.warn('[LiveSync] Save returned status:', response.status);
|
|
1003
|
+
retry = response.status >= 500 || response.status === 408 || response.status === 429;
|
|
971
1004
|
}
|
|
972
1005
|
}).catch(err => {
|
|
973
1006
|
console.error('[LiveSync] Save failed:', err);
|
|
974
1007
|
if (this.onError) this.onError(err);
|
|
1008
|
+
retry = true;
|
|
975
1009
|
}).finally(() => {
|
|
976
1010
|
this._sendInFlight = false;
|
|
977
1011
|
const queued = this._queuedSend;
|
|
978
1012
|
this._queuedSend = null;
|
|
979
|
-
if (queued)
|
|
1013
|
+
if (queued) {
|
|
1014
|
+
this._postUpdate(queued.html, queued.identityMap, queued.etag);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
// The relay of a landed save is the only one peers get, so a transient
|
|
1018
|
+
// failure is retried a few times. A newer save queued meanwhile replaces
|
|
1019
|
+
// it: peers only need the latest.
|
|
1020
|
+
if (!retry || !etag || attempt >= 3 || this.isDestroyed) return;
|
|
1021
|
+
this._sendInFlight = true;
|
|
1022
|
+
setTimeout(() => {
|
|
1023
|
+
this._sendInFlight = false;
|
|
1024
|
+
if (this.isDestroyed) return;
|
|
1025
|
+
const newer = this._queuedSend;
|
|
1026
|
+
this._queuedSend = null;
|
|
1027
|
+
if (newer) this._postUpdate(newer.html, newer.identityMap, newer.etag);
|
|
1028
|
+
else this._postUpdate(html, identityMap, etag, attempt + 1);
|
|
1029
|
+
}, 500 * 2 ** attempt);
|
|
980
1030
|
});
|
|
981
1031
|
}
|
|
982
1032
|
|
|
@@ -1445,6 +1495,7 @@ class LiveSync {
|
|
|
1445
1495
|
|
|
1446
1496
|
let diverged = false;
|
|
1447
1497
|
let typedDuringWait = false;
|
|
1498
|
+
let stamped = false;
|
|
1448
1499
|
try {
|
|
1449
1500
|
// Hold the whole frame only when this tab has unsaved edits and no
|
|
1450
1501
|
// baseline to merge them against (the first frame of a fresh
|
|
@@ -1522,7 +1573,10 @@ class LiveSync {
|
|
|
1522
1573
|
// above, and deliberately the same moment: the two claims a page makes
|
|
1523
1574
|
// when it adopts a stamp are "the host stores this version" and "I hold
|
|
1524
1575
|
// it", and only the second one is this tab's to make.
|
|
1525
|
-
if (typeof etag === 'string' && etag)
|
|
1576
|
+
if (typeof etag === 'string' && etag) {
|
|
1577
|
+
recordEtag(etag);
|
|
1578
|
+
stamped = true;
|
|
1579
|
+
}
|
|
1526
1580
|
|
|
1527
1581
|
// Cross-lane baseline: the DOM now holds this frame's content, but the
|
|
1528
1582
|
// DISK baseline (lastSavedContents) still describes pre-frame state. A
|
|
@@ -1593,9 +1647,12 @@ class LiveSync {
|
|
|
1593
1647
|
// edits + their frame) that exists only in this DOM. Push it out explicitly
|
|
1594
1648
|
// — the morph ran under Mutation.pause, so no autosave was triggered, and a
|
|
1595
1649
|
// pending autosave debounce may already have fired mid-flight. Runs after
|
|
1596
|
-
// isPaused is lifted so the save's
|
|
1650
|
+
// isPaused is lifted so the convergence save's relay reaches peers.
|
|
1597
1651
|
// Only where autosave is on: a manual-save page must never auto-write, so
|
|
1598
1652
|
// there the merge stays local, still dirty, until the person saves.
|
|
1653
|
+
// Not over a lost conflict: the hold and its bar stay up as the signal that
|
|
1654
|
+
// this tab lost text, until the person saves.
|
|
1655
|
+
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
1599
1656
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1600
1657
|
}
|
|
1601
1658
|
|
|
@@ -1626,6 +1683,7 @@ class LiveSync {
|
|
|
1626
1683
|
|
|
1627
1684
|
let diverged = false;
|
|
1628
1685
|
let typedDuringWait = false;
|
|
1686
|
+
let stamped = false;
|
|
1629
1687
|
try {
|
|
1630
1688
|
// Hold the whole frame only when this tab has unsaved edits and no
|
|
1631
1689
|
// baseline to merge them against. Lane-guarded like the peer path: a
|
|
@@ -1682,7 +1740,10 @@ class LiveSync {
|
|
|
1682
1740
|
// A frame with no stamp (an older host, or the content-less fetch fallback,
|
|
1683
1741
|
// which serves bytes nobody stamped) leaves this alone, and the listener in
|
|
1684
1742
|
// etag.js falls back to asking the host.
|
|
1685
|
-
if (typeof etag === 'string' && etag)
|
|
1743
|
+
if (typeof etag === 'string' && etag) {
|
|
1744
|
+
recordEtag(etag);
|
|
1745
|
+
stamped = true;
|
|
1746
|
+
}
|
|
1686
1747
|
|
|
1687
1748
|
if (
|
|
1688
1749
|
this.lane === 'live' &&
|
|
@@ -1733,6 +1794,9 @@ class LiveSync {
|
|
|
1733
1794
|
// (our edits + their bytes). The baseline was left pre-external, so the
|
|
1734
1795
|
// save sees both as changes and writes the merge back. Only where
|
|
1735
1796
|
// autosave is on, as in the peer lane.
|
|
1797
|
+
// Not over a lost conflict: the hold and its bar stay up as the signal that
|
|
1798
|
+
// this tab lost text, until the person saves.
|
|
1799
|
+
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
1736
1800
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1737
1801
|
}
|
|
1738
1802
|
|