@panphora/clayjs 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/clay.standalone.js +6972 -3942
- package/entries/clay-data.js +1 -1
- package/entries/sap.js +6 -4
- package/package.json +1 -1
- package/src/core/admin-contenteditable.js +4 -1
- package/src/core/admin-inputs.js +23 -13
- package/src/core/admin-onclick.js +5 -0
- package/src/core/autosave.js +2 -0
- package/src/core/is-edit-mode.js +7 -2
- package/src/core/save.js +22 -4
- package/src/core/snapshot.js +46 -3
- package/src/core/source-map.js +246 -38
- package/src/lib/autosave-state.js +17 -0
- package/src/lib/dirty-gate.js +5 -0
- package/src/loader-logic.js +8 -2
- package/src/loader.js +2 -0
- package/src/plugins/ai-edit.js +625 -0
- package/src/plugins/source.js +150 -66
- package/src/sync/live-sync.js +467 -311
- package/src/sync/section-notice.js +6 -7
- package/src/vendor/control-serialize.vendor.js +13 -6
- package/src/vendor/hyper-morph.vendor.js +4 -9
- package/src/vendor/hypercms.vendor.js +38 -38
- package/src/sync/splice-merge.js +0 -270
package/src/sync/live-sync.js
CHANGED
|
@@ -36,32 +36,115 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
import { HyperMorph, morph } from "../vendor/hyper-morph.vendor.js";
|
|
39
|
+
|
|
40
|
+
const { createIdentityStore } = HyperMorph;
|
|
39
41
|
import Mutation from "../lib/mutation.js";
|
|
40
|
-
import {
|
|
42
|
+
import { NO_DIRTY_SELECTOR } from "../lib/region-policy.js";
|
|
43
|
+
import { EXTENSION_NODE_SELECTOR } from '../lib/extension-noise.js';
|
|
41
44
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
42
45
|
import { mergeTagRecognizers } from "./merge-tags.js";
|
|
43
|
-
import { serializeForSync, captureForComparisonAndDirty, captureSnapshot, originalSnapshotNode } from '../core/snapshot.js';
|
|
44
|
-
import { isTabLocalRootAttr } from '../lib/root-attrs.js';
|
|
45
|
-
import {
|
|
46
|
+
import { serializeForSync, captureForComparisonAndDirty, captureSnapshot, originalSnapshotNode, captureForMerge, captureForSaveAndComparison } from '../core/snapshot.js';
|
|
47
|
+
import { isTabLocalRootAttr, TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
|
|
48
|
+
import { autosaveActive } from '../lib/autosave-state.js';
|
|
49
|
+
import { enableContentEditable } from '../core/admin-contenteditable.js';
|
|
50
|
+
import { enableOnClick } from '../core/admin-onclick.js';
|
|
51
|
+
import { enableAdminInputs } from '../core/admin-inputs.js';
|
|
52
|
+
import { enableAdminResources } from '../core/admin-resources.js';
|
|
46
53
|
import { presence } from './presence.js';
|
|
47
54
|
// Side-effect import: the section-changed notice wires itself to
|
|
48
55
|
// `clay:sync-applied`, which this file is the only dispatcher of.
|
|
49
56
|
import './section-notice.js';
|
|
50
57
|
import { hostMeta } from '../core/host-meta.js';
|
|
51
58
|
import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
|
|
52
|
-
import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
|
|
59
|
+
import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged, gateMarkDirty } from '../lib/dirty-gate.js';
|
|
53
60
|
import { SyncStream } from './stream.js';
|
|
54
61
|
|
|
62
|
+
// What a live-sync merge never reads or touches on any side: editor chrome,
|
|
63
|
+
// content kept out of the save or the snapshot, frozen regions, and nodes
|
|
64
|
+
// browser extensions inject. The same set the 0.5 morph's sync policy ignored.
|
|
65
|
+
const SYNC_IGNORE_SELECTOR = [
|
|
66
|
+
'[editor-ui]', '[clay~="editor-ui"]', '[save-ignore]', '[snapshot-remove]',
|
|
67
|
+
'[no-snapshot]', '[no-save]', '[save-remove]', '[freeze]', '[save-freeze]',
|
|
68
|
+
'[clay~="no-save"]', '[clay~="no-snapshot"]', '[clay~="freeze"]',
|
|
69
|
+
EXTENSION_NODE_SELECTOR,
|
|
70
|
+
].join(',');
|
|
71
|
+
|
|
72
|
+
// Regions whose local state is never work to keep: disposable content
|
|
73
|
+
// (no-dirty) and regions the mutation system does not watch, whose churn the
|
|
74
|
+
// dirty gate already excludes. The incoming frame's version wins there.
|
|
75
|
+
const REMOTE_WINS_SELECTOR = [
|
|
76
|
+
NO_DIRTY_SELECTOR,
|
|
77
|
+
'[clay~="no-watch"]', '[no-watch]', '[mutations-ignore]',
|
|
78
|
+
].join(',');
|
|
79
|
+
|
|
80
|
+
// Host tokens and library state on a document's root element, on every side
|
|
81
|
+
// of a merge: the live root, a snapshot clone's root, a parsed frame's root.
|
|
82
|
+
const isRootLocalAttr = (el, name) =>
|
|
83
|
+
!el.parentElement && el.localName === 'html' && TAB_LOCAL_ROOT_ATTRS.has(name);
|
|
84
|
+
|
|
85
|
+
// Identity an author wrote into the page, the fallback behind synthetic ids.
|
|
86
|
+
//
|
|
87
|
+
// Answered only where it is unique on its side. hyper-morph drops a duplicated
|
|
88
|
+
// id from its own index, but an identity answered here pre-empts the sender's
|
|
89
|
+
// map (`first`), so cards cloned from one template, all carrying the same
|
|
90
|
+
// data-id, lost their synthetic ids and paired by nothing while their children
|
|
91
|
+
// kept theirs and cross-paired. Answering null lets the map speak.
|
|
92
|
+
//
|
|
93
|
+
// `memo` caches one count per side tree for the length of one merge. It must
|
|
94
|
+
// not outlive the merge: a two-way call reads the live document as its local
|
|
95
|
+
// side, and the live document changes between frames.
|
|
96
|
+
const authoredAttr = (el) => el.getAttribute('data-id') || el.getAttribute('id') || null;
|
|
97
|
+
|
|
98
|
+
function countAuthored(root) {
|
|
99
|
+
const counts = new Map();
|
|
100
|
+
const visit = (el) => {
|
|
101
|
+
const id = authoredAttr(el);
|
|
102
|
+
if (id) counts.set(id, (counts.get(id) || 0) + 1);
|
|
103
|
+
const kids = el.localName === 'template' && el.content ? el.content.children : el.children;
|
|
104
|
+
for (const kid of kids) visit(kid);
|
|
105
|
+
};
|
|
106
|
+
const tops = root.nodeType === 9 ? [root.documentElement] : root.nodeType === 11 ? root.children : [root];
|
|
107
|
+
for (const top of tops) if (top && top.nodeType === 1) visit(top);
|
|
108
|
+
return counts;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const uniqueAuthoredIdentity = (memo) => (el) => {
|
|
112
|
+
const id = authoredAttr(el);
|
|
113
|
+
if (!id) return null;
|
|
114
|
+
const root = el.getRootNode();
|
|
115
|
+
let counts = memo.get(root);
|
|
116
|
+
if (!counts) memo.set(root, (counts = countAuthored(root)));
|
|
117
|
+
return counts.get(id) === 1 ? id : null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const isIdMap = (m) => !!m && typeof m === 'object' && !Array.isArray(m);
|
|
121
|
+
|
|
55
122
|
// The page just took a frame verified clean against its baseline, so it now IS
|
|
56
123
|
// the file on disk. Both saved baselines move together from one capture: leaving
|
|
57
124
|
// the dirty baseline behind would make the close warning fire on the frame's own
|
|
58
125
|
// content. Never flushes undo (this runs per incoming frame, not per save) and
|
|
59
126
|
// never emits snapshot-ready (it must not feed the send pipeline).
|
|
127
|
+
/**
|
|
128
|
+
* Convert an incoming disk document from its saved (inert) form to the live
|
|
129
|
+
* edit-mode form, exactly as boot does on page load, so the merge compares
|
|
130
|
+
* like with like: without this, every disk frame would swap the live page's
|
|
131
|
+
* contenteditable/onclick/admin state back to inert, killing the caret
|
|
132
|
+
* mid-edit. Root attributes (editmode, savestatus, tokens) need no reversal
|
|
133
|
+
* here; the merge's attribute veto keeps the live root's own.
|
|
134
|
+
*/
|
|
135
|
+
function activateIncomingDoc(rootEl) {
|
|
136
|
+
if (!isEditMode || !rootEl) return;
|
|
137
|
+
enableContentEditable(rootEl);
|
|
138
|
+
enableOnClick(rootEl);
|
|
139
|
+
enableAdminInputs(rootEl);
|
|
140
|
+
enableAdminResources(rootEl);
|
|
141
|
+
}
|
|
142
|
+
|
|
60
143
|
function pairedBaseline() {
|
|
61
144
|
const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
|
|
62
145
|
return [forComparison, forDirty];
|
|
63
146
|
}
|
|
64
|
-
import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges } from '../core/save.js';
|
|
147
|
+
import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes } from '../core/save.js';
|
|
65
148
|
|
|
66
149
|
/**
|
|
67
150
|
* The two live-sync wires, and the rule for choosing between them.
|
|
@@ -113,6 +196,27 @@ class LiveSync {
|
|
|
113
196
|
this.sse = null;
|
|
114
197
|
this.currentFile = null;
|
|
115
198
|
this.lastHtml = null;
|
|
199
|
+
// The disk lane's merge base: the file's bytes, in the save domain, as of
|
|
200
|
+
// the most recent moment this tab's document agreed with the file. Three
|
|
201
|
+
// things are such a moment: the seed at start(), a frame's synchronous
|
|
202
|
+
// apply, and a save's capture. Each takes a ticket from _agreeSeq at that
|
|
203
|
+
// moment and writes through _setDiskBase, which keeps the newest moment:
|
|
204
|
+
// a save response landing after a later frame applied, or a frame whose
|
|
205
|
+
// await outlasts a later save, can no longer rewind the base.
|
|
206
|
+
this._diskBase = null;
|
|
207
|
+
this._diskBaseTicket = 0;
|
|
208
|
+
this._agreeSeq = 0;
|
|
209
|
+
// The ticket of the save currently in flight, taken when it captured.
|
|
210
|
+
this._saveTicket = 0;
|
|
211
|
+
// Conflicts an incoming frame won over this tab's unsaved text, kept until
|
|
212
|
+
// this tab's next successful save. While any are here the page stays
|
|
213
|
+
// dirty: no clean frame clears the gate or advances a saved baseline, and
|
|
214
|
+
// no automatic save runs from the merge path, so the close warning holds
|
|
215
|
+
// and the lost text stays reachable. Each entry is a hyper-morph conflict
|
|
216
|
+
// (`local` is the text this tab lost).
|
|
217
|
+
this.unresolvedConflicts = [];
|
|
218
|
+
// Each conflict's ticket: the moment its frame applied.
|
|
219
|
+
this._conflictTickets = new WeakMap();
|
|
116
220
|
this.clientId = this.generateClientId();
|
|
117
221
|
|
|
118
222
|
// Per-stream resume id for the htmlclay replay server's wire contract.
|
|
@@ -227,12 +331,11 @@ class LiveSync {
|
|
|
227
331
|
|
|
228
332
|
// Identity tracking for content-based morphing across live-sync updates.
|
|
229
333
|
// Synthetic IDs (`<clientId>:<counter>`) live here only — never written to
|
|
230
|
-
// the DOM, never serialized into saved HTML.
|
|
334
|
+
// the DOM, never serialized into saved HTML. hyper-morph’s identity store holds them
|
|
231
335
|
// against the live elements so that the next save can produce the same
|
|
232
336
|
// identityMap, and afterNodeMorphed transfers IDs from incoming parsed
|
|
233
337
|
// elements onto the live elements they morphed into.
|
|
234
|
-
this.
|
|
235
|
-
this.liveWeakMap = new WeakMap();
|
|
338
|
+
this.identity = createIdentityStore(this.clientId);
|
|
236
339
|
|
|
237
340
|
// Callbacks
|
|
238
341
|
this.onConnect = null;
|
|
@@ -303,6 +406,12 @@ class LiveSync {
|
|
|
303
406
|
// so mint a fresh resume id — this stream must not resume the previous one.
|
|
304
407
|
this.lastHtml = null;
|
|
305
408
|
this._lastIdentityMap = null;
|
|
409
|
+
// The reset takes a ticket of its own, so a writer from before this start
|
|
410
|
+
// (a save response still on the wire) cannot land in the new run.
|
|
411
|
+
this._diskBase = null;
|
|
412
|
+
this._diskBaseTicket = this._ticket();
|
|
413
|
+
this._saveTicket = 0;
|
|
414
|
+
this.unresolvedConflicts = [];
|
|
306
415
|
this._savedSnapshot = null;
|
|
307
416
|
this._applyGen++;
|
|
308
417
|
this.lastSeenSeq = 0;
|
|
@@ -310,6 +419,17 @@ class LiveSync {
|
|
|
310
419
|
this._pendingExternal = null;
|
|
311
420
|
this.resumeId = this.generateResumeId();
|
|
312
421
|
|
|
422
|
+
// Seed both merge bases (the peer lane's and the disk lane's) with the page
|
|
423
|
+
// as served, so a first frame that arrives after local edits merges instead
|
|
424
|
+
// of holding. A page already dirty at start has no trustworthy base; it
|
|
425
|
+
// keeps null and holds.
|
|
426
|
+
if (this.lane === 'live' && !pageMaybeDirty()) {
|
|
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
|
+
}
|
|
432
|
+
|
|
313
433
|
console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
|
|
314
434
|
// One discovery request stands between here and the stream. It is memoized and
|
|
315
435
|
// bounded, and it does not gate ordinary saves — /_/save is a separate lane that
|
|
@@ -327,6 +447,23 @@ class LiveSync {
|
|
|
327
447
|
this.listenForSnapshots();
|
|
328
448
|
this._saveSavedHandler = () => {
|
|
329
449
|
this._saveEpoch++;
|
|
450
|
+
// The file now holds what this save wrote: the disk lane's new base,
|
|
451
|
+
// dated at the save's capture, which is when the document held these
|
|
452
|
+
// bytes. A frame that applied after that capture is newer and keeps
|
|
453
|
+
// the base. A save this instance never saw capture (there is none
|
|
454
|
+
// today) counts as now.
|
|
455
|
+
const ticket = this._saveTicket || this._ticket();
|
|
456
|
+
this._saveTicket = 0;
|
|
457
|
+
const bytes = getLastSavedBytes();
|
|
458
|
+
if (bytes != null) this._setDiskBase(bytes, ticket);
|
|
459
|
+
// A save this tab made is the one act that acknowledges a lost
|
|
460
|
+
// conflict: the person wrote the page as it stands. Only the page as
|
|
461
|
+
// it stood at the capture: a conflict from a frame that applied after
|
|
462
|
+
// it is not in these bytes, and it keeps the page dirty.
|
|
463
|
+
this.unresolvedConflicts = this.unresolvedConflicts.filter(
|
|
464
|
+
(c) => this._conflictTickets.get(c) > ticket
|
|
465
|
+
);
|
|
466
|
+
if (this.unresolvedConflicts.length) gateMarkDirty();
|
|
330
467
|
this._relayCommit();
|
|
331
468
|
};
|
|
332
469
|
document.addEventListener('clay:save-saved', this._saveSavedHandler);
|
|
@@ -378,113 +515,6 @@ class LiveSync {
|
|
|
378
515
|
this._holdRetryExt = null;
|
|
379
516
|
}
|
|
380
517
|
|
|
381
|
-
_mintId() {
|
|
382
|
-
this.idCounter++;
|
|
383
|
-
return `${this.clientId}:${this.idCounter}`;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Walk the live DOM and the snapshot clone in lockstep. Path keys come
|
|
388
|
-
* from the clone (= what the receiver will see, after [snapshot-remove]
|
|
389
|
-
* and snapshotHooks). WeakMap lookup happens against the live element so
|
|
390
|
-
* synthetic IDs persist across saves.
|
|
391
|
-
*
|
|
392
|
-
* The live walk filters [snapshot-remove] to mirror the clone's earlier
|
|
393
|
-
* strip in captureSnapshot. If child counts diverge anywhere (an
|
|
394
|
-
* onbeforesnapshot handler added/removed siblings on the clone), the
|
|
395
|
-
* subtree is skipped — better to fall back to content scoring there than
|
|
396
|
-
* emit misaligned IDs.
|
|
397
|
-
*
|
|
398
|
-
* @param {Element} liveRoot
|
|
399
|
-
* @param {Element} cloneRoot
|
|
400
|
-
* @returns {Object} identityMap keyed by dot-path
|
|
401
|
-
*/
|
|
402
|
-
_buildIdentityMap(liveRoot, cloneRoot) {
|
|
403
|
-
const map = {};
|
|
404
|
-
if (!liveRoot || !cloneRoot) return map;
|
|
405
|
-
|
|
406
|
-
const visit = (clone, path) => {
|
|
407
|
-
const live = originalSnapshotNode(clone);
|
|
408
|
-
if (live) {
|
|
409
|
-
let id = this.liveWeakMap.get(live);
|
|
410
|
-
if (!id) {
|
|
411
|
-
id = this._mintId();
|
|
412
|
-
this.liveWeakMap.set(live, id);
|
|
413
|
-
}
|
|
414
|
-
map[path] = id;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const cloneKids = clone.children;
|
|
418
|
-
|
|
419
|
-
for (let i = 0; i < cloneKids.length; i++) {
|
|
420
|
-
visit(cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
421
|
-
}
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
visit(cloneRoot, '');
|
|
425
|
-
return map;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Walk a single parsed tree, invoking cb(element, path) at each Element.
|
|
430
|
-
* Paths use the same dot-segment scheme as _buildIdentityMap so the
|
|
431
|
-
* receiver can look up IDs by the path the sender emitted.
|
|
432
|
-
*/
|
|
433
|
-
_walkParsedTree(root, cb) {
|
|
434
|
-
if (!root) return;
|
|
435
|
-
const visit = (el, path) => {
|
|
436
|
-
cb(el, path);
|
|
437
|
-
const kids = el.children;
|
|
438
|
-
for (let i = 0; i < kids.length; i++) {
|
|
439
|
-
visit(kids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
440
|
-
}
|
|
441
|
-
};
|
|
442
|
-
visit(root, '');
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* Fill liveWeakMap entries for live elements that the matcher's
|
|
447
|
-
* afterNodeMorphed didn't reach. createNode's no-id-children
|
|
448
|
-
* optimization (hyper-morph importNode path) inserts a clone of the
|
|
449
|
-
* parsed element without invoking morphNode, so afterNodeMorphed never
|
|
450
|
-
* fires for those subtrees and their synthetic IDs would be lost. On
|
|
451
|
-
* the receiver's next save, _buildIdentityMap would mint fresh IDs for
|
|
452
|
-
* the same logical elements, breaking convergence for newly-added
|
|
453
|
-
* ambiguous siblings — exactly the case identity-map exists to fix.
|
|
454
|
-
*
|
|
455
|
-
* Walks live and parsed in lockstep, reading ids off the parsed NODES
|
|
456
|
-
* (parsedWeakMap) rather than re-deriving dot-paths: a protected splice
|
|
457
|
-
* shifts paths, but the WeakMap entries ride the nodes and stay correct.
|
|
458
|
-
* Filters [snapshot-remove] from the live side to stay aligned with the
|
|
459
|
-
* sender's clone view. Aborts a subtree on child-count divergence (e.g.
|
|
460
|
-
* local save-ignore additions) — those elements fall through to content
|
|
461
|
-
* scoring on the next round, which is the same fallback as a sender-side
|
|
462
|
-
* lockstep skip.
|
|
463
|
-
*
|
|
464
|
-
* @param {Element} liveRoot - post-morph live tree root
|
|
465
|
-
* @param {Element} parsedRoot - parsed-tree root
|
|
466
|
-
* @param {WeakMap} parsedWeakMap - parsed node → synthetic id
|
|
467
|
-
*/
|
|
468
|
-
_fillInIdsAfterMorph(liveRoot, parsedRoot, parsedWeakMap) {
|
|
469
|
-
if (!liveRoot || !parsedRoot || !parsedWeakMap) return;
|
|
470
|
-
const visit = (live, parsed) => {
|
|
471
|
-
const id = parsedWeakMap.get(parsed);
|
|
472
|
-
if (id && !this.liveWeakMap.has(live)) {
|
|
473
|
-
this.liveWeakMap.set(live, id);
|
|
474
|
-
}
|
|
475
|
-
const liveKids = [];
|
|
476
|
-
for (const c of live.children) {
|
|
477
|
-
if (!isSnapshotRemoved(c)) liveKids.push(c);
|
|
478
|
-
}
|
|
479
|
-
const parsedKids = parsed.children;
|
|
480
|
-
if (liveKids.length !== parsedKids.length) return;
|
|
481
|
-
for (let i = 0; i < liveKids.length; i++) {
|
|
482
|
-
visit(liveKids[i], parsedKids[i]);
|
|
483
|
-
}
|
|
484
|
-
};
|
|
485
|
-
visit(liveRoot, parsedRoot);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
518
|
/**
|
|
489
519
|
* Auto-detect the current site file path from the URL
|
|
490
520
|
* Returns the path including extension (e.g., card-canvas.html)
|
|
@@ -755,6 +785,11 @@ class LiveSync {
|
|
|
755
785
|
*/
|
|
756
786
|
listenForSnapshots() {
|
|
757
787
|
this._snapshotHandler = (event) => {
|
|
788
|
+
// 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. Taken
|
|
790
|
+
// ahead of the pause check: a save made during a frame's await is still a
|
|
791
|
+
// save, and it is newer than that frame.
|
|
792
|
+
this._saveTicket = this._ticket();
|
|
758
793
|
if (this.isPaused) {
|
|
759
794
|
this._log('snapshot-ready received but isPaused, skipping');
|
|
760
795
|
return;
|
|
@@ -769,7 +804,7 @@ class LiveSync {
|
|
|
769
804
|
// must happen now.
|
|
770
805
|
this._log('snapshot-ready received, preparing to send');
|
|
771
806
|
const html = serializeForSync(clone);
|
|
772
|
-
const identityMap = this.
|
|
807
|
+
const identityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
773
808
|
|
|
774
809
|
// The save that follows this capture stores these bytes, so this is the
|
|
775
810
|
// content its stamp will describe. Held for _relayCommit, and overwritten
|
|
@@ -1184,6 +1219,21 @@ class LiveSync {
|
|
|
1184
1219
|
}
|
|
1185
1220
|
}
|
|
1186
1221
|
|
|
1222
|
+
/** A ticket for one moment the document agreed with the file. Later is greater. */
|
|
1223
|
+
_ticket() {
|
|
1224
|
+
return ++this._agreeSeq;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Set the disk lane's base to `bytes`, dated at `ticket`, unless a later
|
|
1229
|
+
* moment already set it. Every writer goes through here; see the field.
|
|
1230
|
+
*/
|
|
1231
|
+
_setDiskBase(bytes, ticket) {
|
|
1232
|
+
if (ticket <= this._diskBaseTicket) return;
|
|
1233
|
+
this._diskBase = bytes;
|
|
1234
|
+
this._diskBaseTicket = ticket;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1187
1237
|
_requestFrame(cb) {
|
|
1188
1238
|
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
1189
1239
|
return window.requestAnimationFrame(cb);
|
|
@@ -1199,6 +1249,175 @@ class LiveSync {
|
|
|
1199
1249
|
clearTimeout(handle);
|
|
1200
1250
|
}
|
|
1201
1251
|
|
|
1252
|
+
/**
|
|
1253
|
+
* Merge an incoming frame (a peer's broadcast or the file's bytes) into the
|
|
1254
|
+
* live document with hyper-morph, and return its report.
|
|
1255
|
+
*
|
|
1256
|
+
* With a base, the merge is three-way: the base the lane last agreed on, this
|
|
1257
|
+
* tab's state captured in the base's serialization domain, and the frame.
|
|
1258
|
+
* Both sides' edits survive; where both changed the same words the frame
|
|
1259
|
+
* wins and the report records a conflict. With no base (a clean tab before
|
|
1260
|
+
* its first seed), the frame is taken two-way. Callers hold before this when
|
|
1261
|
+
* the tab is dirty and has no base.
|
|
1262
|
+
*
|
|
1263
|
+
* Authored ids (data-id, id) outrank synthetic ones on every side, so a map
|
|
1264
|
+
* can never pair an element against a different element that carries the
|
|
1265
|
+
* same authored id.
|
|
1266
|
+
*
|
|
1267
|
+
* @param {string} html - the incoming frame
|
|
1268
|
+
* @param {object|null} identityMap - the sender's synthetic ids, if any
|
|
1269
|
+
* @param {object} lane
|
|
1270
|
+
* @param {string|null} lane.base - the lane's merge base
|
|
1271
|
+
* @param {object|null} [lane.baseIdentityMap] - synthetic ids for the base
|
|
1272
|
+
* @param {() => Element} lane.captureLocal - this tab's state in the base's domain
|
|
1273
|
+
* @param {boolean} lane.synthetic - whether this tab's synthetic ids apply
|
|
1274
|
+
* @param {object} [lane.extra] - extra mergeDocument options (the disk lane's beforeApply)
|
|
1275
|
+
*/
|
|
1276
|
+
async _mergeIncoming(html, identityMap, { base, baseIdentityMap = null, captureLocal, synthetic, extra = {} }) {
|
|
1277
|
+
const store = this.identity;
|
|
1278
|
+
// A frame may neither write these onto our root nor, by not carrying them,
|
|
1279
|
+
// take ours away. Returning false is hyper-morph's veto for both directions.
|
|
1280
|
+
const beforeAttributeUpdated = (name, element) =>
|
|
1281
|
+
isTabLocalRootAttr(name, element) ? false : undefined;
|
|
1282
|
+
const authored = uniqueAuthoredIdentity(new WeakMap());
|
|
1283
|
+
const sideIdentity = (map) =>
|
|
1284
|
+
isIdMap(map) ? { first: authored, map, then: authored } : authored;
|
|
1285
|
+
const localIdentity = synthetic
|
|
1286
|
+
? (el) => authored(el) || store.idOf(originalSnapshotNode(el) || el) || null
|
|
1287
|
+
: authored;
|
|
1288
|
+
|
|
1289
|
+
// Elements the frame names by authored id may carry a synthetic id in the
|
|
1290
|
+
// sender's map too, and this tab's element with that authored id has to
|
|
1291
|
+
// adopt it: a receiver on the older runtime keys its live elements by
|
|
1292
|
+
// synthetic id alone, and a frame from this tab that used a fresh synthetic
|
|
1293
|
+
// id for the same card would make that receiver replace the card, losing
|
|
1294
|
+
// its listeners and state. The frame is parsed here so the map can be
|
|
1295
|
+
// resolved against the same nodes hyper-morph will see; a Document is
|
|
1296
|
+
// taken as given, so nothing is parsed twice.
|
|
1297
|
+
let remote = html;
|
|
1298
|
+
const syntheticByAuthored = new Map();
|
|
1299
|
+
if (isIdMap(identityMap)) {
|
|
1300
|
+
remote = new DOMParser().parseFromString(html, 'text/html');
|
|
1301
|
+
const imported = HyperMorph.importMap(remote.documentElement, identityMap);
|
|
1302
|
+
for (const el of remote.querySelectorAll('[data-id], [id]')) {
|
|
1303
|
+
const id = authored(el);
|
|
1304
|
+
const synthetic = id && imported.get(el);
|
|
1305
|
+
if (synthetic) syntheticByAuthored.set(id, synthetic);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const common = {
|
|
1310
|
+
live: document,
|
|
1311
|
+
remote,
|
|
1312
|
+
ignore: (el) => el.matches(SYNC_IGNORE_SELECTOR),
|
|
1313
|
+
remoteWins: (el) => el.matches(REMOTE_WINS_SELECTOR),
|
|
1314
|
+
ignoreAttribute: isRootLocalAttr,
|
|
1315
|
+
protectFocusedValue: true,
|
|
1316
|
+
scripts: { mergeTags: mergeTagRecognizers },
|
|
1317
|
+
hooks: { beforeAttributeUpdated },
|
|
1318
|
+
...extra,
|
|
1319
|
+
};
|
|
1320
|
+
// The gate token is taken before the capture so a clear can never swallow
|
|
1321
|
+
// typing that lands during the merge. It is also how typing during the
|
|
1322
|
+
// await is noticed at all: the mutation feed is paused for the apply and
|
|
1323
|
+
// vacuumed on resume, so nothing else schedules a save for it.
|
|
1324
|
+
const dirty = this.lane === 'live' && pageMaybeDirty();
|
|
1325
|
+
const token = gateCaptureToken();
|
|
1326
|
+
// We MUST await: the Promise resolves once new external scripts have
|
|
1327
|
+
// loaded and run. Resuming mutations earlier would let late scripts' DOM
|
|
1328
|
+
// changes look like user edits and echo back out.
|
|
1329
|
+
let pending;
|
|
1330
|
+
if (base == null) {
|
|
1331
|
+
pending = HyperMorph.mergeDocument({
|
|
1332
|
+
...common,
|
|
1333
|
+
base: null,
|
|
1334
|
+
identity: { local: localIdentity, remote: sideIdentity(identityMap) },
|
|
1335
|
+
});
|
|
1336
|
+
} else if (!dirty) {
|
|
1337
|
+
// A clean tab has no local changes, so its own capture stands in for the
|
|
1338
|
+
// base too: base and local are one tree. Merging the lane's recorded
|
|
1339
|
+
// base against a fresh capture would instead report every place the
|
|
1340
|
+
// live DOM does not round-trip through serialization (a raw <noscript>,
|
|
1341
|
+
// a <div> the parser lifted out of a <p>, a <table> given a <tbody>) as
|
|
1342
|
+
// a local edit, and a clean autosave tab would write that "edit" back to
|
|
1343
|
+
// disk on every frame. Still three-way: merge tags keep their base for
|
|
1344
|
+
// the JSON merge, and hyper-morph's clean-side shortcuts apply.
|
|
1345
|
+
const localRoot = captureLocal();
|
|
1346
|
+
const baseDoc = localRoot.ownerDocument;
|
|
1347
|
+
if (baseDoc.documentElement !== localRoot) baseDoc.replaceChild(localRoot, baseDoc.documentElement);
|
|
1348
|
+
pending = HyperMorph.mergeDocument({
|
|
1349
|
+
...common,
|
|
1350
|
+
base: baseDoc,
|
|
1351
|
+
local: { root: localRoot, toLive: originalSnapshotNode },
|
|
1352
|
+
identity: {
|
|
1353
|
+
base: localIdentity,
|
|
1354
|
+
local: localIdentity,
|
|
1355
|
+
remote: sideIdentity(identityMap),
|
|
1356
|
+
},
|
|
1357
|
+
});
|
|
1358
|
+
} else {
|
|
1359
|
+
pending = HyperMorph.mergeDocument({
|
|
1360
|
+
...common,
|
|
1361
|
+
base,
|
|
1362
|
+
local: { root: captureLocal(), toLive: originalSnapshotNode },
|
|
1363
|
+
identity: {
|
|
1364
|
+
base: sideIdentity(baseIdentityMap),
|
|
1365
|
+
local: localIdentity,
|
|
1366
|
+
remote: sideIdentity(identityMap),
|
|
1367
|
+
},
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
// The apply itself ran synchronously inside mergeDocument; only resource
|
|
1371
|
+
// loads are still pending. This is the moment the frame's bytes were true
|
|
1372
|
+
// of the document, so the lane dates its base here, not after the await:
|
|
1373
|
+
// a save that captures during the wait is newer than this frame.
|
|
1374
|
+
const ticket = this._ticket();
|
|
1375
|
+
const report = await pending;
|
|
1376
|
+
const typedDuringWait = gateCaptureToken().gen !== token.gen;
|
|
1377
|
+
|
|
1378
|
+
// A conflict the frame won leaves this tab's text in report.conflicts and
|
|
1379
|
+
// nowhere else. It is kept until this tab's next successful save, so the
|
|
1380
|
+
// page stays dirty (the close warning holds) however many clean frames
|
|
1381
|
+
// follow, and nothing writes the page out from under it automatically.
|
|
1382
|
+
if (dirty && report.conflicts.length) {
|
|
1383
|
+
for (const c of report.conflicts) this._conflictTickets.set(c, ticket);
|
|
1384
|
+
this.unresolvedConflicts.push(...report.conflicts);
|
|
1385
|
+
}
|
|
1386
|
+
// Clean only when nothing of this tab's survived, nothing of it was lost,
|
|
1387
|
+
// and nothing lost earlier is still waiting on a save.
|
|
1388
|
+
if (
|
|
1389
|
+
dirty &&
|
|
1390
|
+
!report.localDiverged &&
|
|
1391
|
+
report.conflicts.length === 0 &&
|
|
1392
|
+
this.unresolvedConflicts.length === 0
|
|
1393
|
+
) {
|
|
1394
|
+
gateClearIfUnchanged(token);
|
|
1395
|
+
}
|
|
1396
|
+
// Synthetic ids converge: every live element the frame named takes the
|
|
1397
|
+
// sender's id, whether the frame named it by that id or by an authored id
|
|
1398
|
+
// the sender's map also covers. Never an authored data-id or id itself.
|
|
1399
|
+
const mapIds = new Set(isIdMap(identityMap) ? Object.values(identityMap) : []);
|
|
1400
|
+
for (const [el, id] of report.identities) {
|
|
1401
|
+
const synthetic = mapIds.has(id) ? id : syntheticByAuthored.get(id);
|
|
1402
|
+
if (synthetic) store.adopt(el, synthetic);
|
|
1403
|
+
}
|
|
1404
|
+
return { report, ticket, typedDuringWait };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* The automatic save a merge may owe: the merge kept this tab's edits
|
|
1409
|
+
* (convergence), or the person typed during the awaited apply (the mutation
|
|
1410
|
+
* feed was paused, so nothing else will schedule it). Only where autosave is
|
|
1411
|
+
* on, and never over an unresolved conflict: that save would write the page
|
|
1412
|
+
* with the lost text gone and clear the one signal that says so.
|
|
1413
|
+
*/
|
|
1414
|
+
_saveAfterMerge(diverged, typedDuringWait) {
|
|
1415
|
+
if (!(diverged || typedDuringWait)) return;
|
|
1416
|
+
if (!autosaveActive()) return;
|
|
1417
|
+
if (this.unresolvedConflicts.length) return;
|
|
1418
|
+
savePageThrottled();
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1202
1421
|
/**
|
|
1203
1422
|
* Actual morph work. Do not call directly. Use applyUpdate() so calls
|
|
1204
1423
|
* pass through the rAF queue and don't overlap.
|
|
@@ -1224,85 +1443,34 @@ class LiveSync {
|
|
|
1224
1443
|
Mutation.pause();
|
|
1225
1444
|
pauseGate();
|
|
1226
1445
|
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
const newDoc = parser.parseFromString(html, 'text/html');
|
|
1230
|
-
|
|
1231
|
-
// Build the parsed-tree WeakMap from the incoming identityMap. The
|
|
1232
|
-
// sender emitted paths off its clone, which is exactly what we just
|
|
1233
|
-
// parsed, so the same path scheme indexes into both trees.
|
|
1234
|
-
const parsedWeakMap = new WeakMap();
|
|
1235
|
-
if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
|
|
1236
|
-
this._walkParsedTree(newDoc.documentElement, (el, path) => {
|
|
1237
|
-
const id = identityMap[path];
|
|
1238
|
-
if (id) parsedWeakMap.set(el, id);
|
|
1239
|
-
});
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
const liveWeakMap = this.liveWeakMap;
|
|
1243
|
-
// Priority: synthetic IDs win when present (they're updated after every
|
|
1244
|
-
// morph via afterNodeMorphed). data-id / id is the durable fallback that
|
|
1245
|
-
// covers the bootstrap window and any element that hasn't been paired yet.
|
|
1246
|
-
const key = (el) =>
|
|
1247
|
-
liveWeakMap.get(el) ||
|
|
1248
|
-
parsedWeakMap.get(el) ||
|
|
1249
|
-
(el.getAttribute && el.getAttribute('data-id')) ||
|
|
1250
|
-
(el.getAttribute && el.getAttribute('id')) ||
|
|
1251
|
-
null;
|
|
1252
|
-
const afterNodeMorphed = (oldEl, newEl) => {
|
|
1253
|
-
const id = parsedWeakMap.get(newEl);
|
|
1254
|
-
if (id) liveWeakMap.set(oldEl, id);
|
|
1255
|
-
};
|
|
1256
|
-
// A peer may neither write these onto our root nor, by not carrying them,
|
|
1257
|
-
// take ours away. Returning false is hyper-morph's veto for both directions
|
|
1258
|
-
// (it calls this for updateType 'update' and 'remove' alike).
|
|
1259
|
-
const beforeAttributeUpdated = (name, element) =>
|
|
1260
|
-
isTabLocalRootAttr(name, element) ? false : undefined;
|
|
1261
|
-
|
|
1262
|
-
let retainedRoots = 0;
|
|
1446
|
+
let diverged = false;
|
|
1447
|
+
let typedDuringWait = false;
|
|
1263
1448
|
try {
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1266
|
-
//
|
|
1267
|
-
//
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
this.
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
clearTimeout(this._holdRetryPeer);
|
|
1290
|
-
this._holdRetryPeer = setTimeout(() => {
|
|
1291
|
-
this._holdRetryPeer = null;
|
|
1292
|
-
if (this.isDestroyed || this._pendingHtml != null) return;
|
|
1293
|
-
// Only while the world hasn't moved: an own save or any newer
|
|
1294
|
-
// frame since the hold makes this payload stale.
|
|
1295
|
-
if (this._saveEpoch !== epochAtHold) return;
|
|
1296
|
-
if (this.lastSeenSeq !== seenAtHold) return;
|
|
1297
|
-
this._pendingHtml = html;
|
|
1298
|
-
this._pendingSeq = seq;
|
|
1299
|
-
this._pendingIdentityMap = identityMap;
|
|
1300
|
-
this._pendingBy = by;
|
|
1301
|
-
this._scheduleNextFrame();
|
|
1302
|
-
}, 3000);
|
|
1303
|
-
return;
|
|
1304
|
-
}
|
|
1305
|
-
retainedRoots = protection.entries.length;
|
|
1449
|
+
// Hold the whole frame only when this tab has unsaved edits and no
|
|
1450
|
+
// baseline to merge them against (the first frame of a fresh
|
|
1451
|
+
// connection). Nothing morphs and no baseline moves; the tab keeps its
|
|
1452
|
+
// local state. Deliberately NO proactive save here: a hold can fire on a
|
|
1453
|
+
// manual-save page, which must never auto-write. The retry re-queues the
|
|
1454
|
+
// same frame; the slot-empty check and the drain's staleness checks drop
|
|
1455
|
+
// it once superseded.
|
|
1456
|
+
if (this.lane === 'live' && pageMaybeDirty() && this.lastHtml === null) {
|
|
1457
|
+
console.log('[LiveSync] Holding incoming update: unsaved local edits and no baseline to merge against');
|
|
1458
|
+
this._setHeld('live', true, null);
|
|
1459
|
+
const epochAtHold = this._saveEpoch;
|
|
1460
|
+
const seenAtHold = this.lastSeenSeq;
|
|
1461
|
+
clearTimeout(this._holdRetryPeer);
|
|
1462
|
+
this._holdRetryPeer = setTimeout(() => {
|
|
1463
|
+
this._holdRetryPeer = null;
|
|
1464
|
+
if (this.isDestroyed || this._pendingHtml != null) return;
|
|
1465
|
+
if (this._saveEpoch !== epochAtHold) return;
|
|
1466
|
+
if (this.lastSeenSeq !== seenAtHold) return;
|
|
1467
|
+
this._pendingHtml = html;
|
|
1468
|
+
this._pendingSeq = seq;
|
|
1469
|
+
this._pendingIdentityMap = identityMap;
|
|
1470
|
+
this._pendingBy = by;
|
|
1471
|
+
this._scheduleNextFrame();
|
|
1472
|
+
}, 3000);
|
|
1473
|
+
return;
|
|
1306
1474
|
}
|
|
1307
1475
|
|
|
1308
1476
|
this._setHeld('live', false);
|
|
@@ -1319,50 +1487,25 @@ class LiveSync {
|
|
|
1319
1487
|
// knowingly missing what disk holds, and a stamp there would let its next
|
|
1320
1488
|
// save replace that change with nobody told.
|
|
1321
1489
|
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
await HyperMorph.morph(document.documentElement, newDoc.documentElement, {
|
|
1328
|
-
morphStyle: 'outerHTML',
|
|
1329
|
-
ignoreActiveValue: true,
|
|
1330
|
-
head: { style: 'merge' },
|
|
1331
|
-
// mergeBase: mergeable script tags ([merge] + rules tags) three-way
|
|
1332
|
-
// merge against the last synced state instead of being clobbered by
|
|
1333
|
-
// the incoming save; lastHtml is exactly that base (set after every
|
|
1334
|
-
// own save and every applied morph). Null on the first update →
|
|
1335
|
-
// two-way merge, which still keeps local-only keys.
|
|
1336
|
-
scripts: {
|
|
1337
|
-
handle: true,
|
|
1338
|
-
matchMode: 'smart',
|
|
1339
|
-
mergeBase: this.lastHtml,
|
|
1340
|
-
mergeTags: mergeTagRecognizers
|
|
1341
|
-
},
|
|
1342
|
-
key,
|
|
1343
|
-
callbacks: { afterNodeMorphed, beforeAttributeUpdated }
|
|
1490
|
+
const { report, typedDuringWait: typed } = await this._mergeIncoming(html, identityMap, {
|
|
1491
|
+
base: this.lastHtml,
|
|
1492
|
+
baseIdentityMap: this._lastIdentityMap,
|
|
1493
|
+
captureLocal: () => captureSnapshot({ flushUndo: false }),
|
|
1494
|
+
synthetic: true,
|
|
1344
1495
|
});
|
|
1496
|
+
diverged = report.localDiverged;
|
|
1497
|
+
typedDuringWait = typed;
|
|
1345
1498
|
|
|
1346
1499
|
// Restore viewport. Done after morph so layout has settled.
|
|
1347
1500
|
window.scrollTo(scrollX, scrollY);
|
|
1348
1501
|
|
|
1349
|
-
// Fill in any IDs the matcher's afterNodeMorphed missed. Brand-new
|
|
1350
|
-
// elements come in via hyper-morph's importNode optimization, which
|
|
1351
|
-
// skips morphNode and thus afterNodeMorphed; their parsedWeakMap IDs
|
|
1352
|
-
// never make it onto liveWeakMap. Without this pass, the receiver
|
|
1353
|
-
// would mint fresh IDs on its next save for those elements,
|
|
1354
|
-
// breaking convergence exactly for newly-added ambiguous siblings.
|
|
1355
|
-
if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
|
|
1356
|
-
this._fillInIdsAfterMorph(document.documentElement, newDoc.documentElement, parsedWeakMap);
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
1502
|
// Only mark lastHtml after a successful morph so that a failed apply
|
|
1360
1503
|
// doesn't desync our state and cause the next outbound save to be
|
|
1361
1504
|
// mistakenly skipped as "unchanged". Note: lastSeenSeq is advanced at
|
|
1362
1505
|
// receive time (in onmessage) so the staleness check covers own-save
|
|
1363
1506
|
// echoes even when they don't reach this point.
|
|
1364
1507
|
//
|
|
1365
|
-
// lastHtml is the RAW incoming frame even after a
|
|
1508
|
+
// lastHtml is the RAW incoming frame even after a merge that kept local edits. A
|
|
1366
1509
|
// patched serialization would poison the next frame's diff base (frame
|
|
1367
1510
|
// two of a burst would read the protected section as clean and clobber
|
|
1368
1511
|
// it) and could dedupe away the convergence send. Convergence is driven
|
|
@@ -1390,8 +1533,31 @@ class LiveSync {
|
|
|
1390
1533
|
// baseline advances here too. Skipped whenever the gate reports dirty
|
|
1391
1534
|
// (including typing that arrived during the morph's async wait, which
|
|
1392
1535
|
// must never be recorded as saved).
|
|
1393
|
-
|
|
1394
|
-
|
|
1536
|
+
//
|
|
1537
|
+
// The same moment moves the DISK LANE's base: the document now agrees
|
|
1538
|
+
// with what the file holds (a peer frame carries committed content, and
|
|
1539
|
+
// this tab kept nothing of its own over it), so the file's bytes, as this
|
|
1540
|
+
// tab would save them, are the disk lane's new base. Without this the
|
|
1541
|
+
// disk lane kept a base from before the peer's edit, and the next disk
|
|
1542
|
+
// frame read the peer's content as this tab's own edit and resurrected it
|
|
1543
|
+
// over a newer revert. Never taken over unsaved local changes: a dirty
|
|
1544
|
+
// gate or a diverged merge leaves the base where it was, and the
|
|
1545
|
+
// convergence save moves it once the merge is on disk.
|
|
1546
|
+
//
|
|
1547
|
+
// Unresolved conflicts keep every saved baseline where it is too: the
|
|
1548
|
+
// page is dirty by definition until this tab saves.
|
|
1549
|
+
if (
|
|
1550
|
+
this.lane === 'live' &&
|
|
1551
|
+
!diverged &&
|
|
1552
|
+
!pageMaybeDirty() &&
|
|
1553
|
+
this.unresolvedConflicts.length === 0
|
|
1554
|
+
) {
|
|
1555
|
+
const { forSave, forComparison, forDirty } = captureForSaveAndComparison({
|
|
1556
|
+
emitForSync: false,
|
|
1557
|
+
flushUndo: false,
|
|
1558
|
+
});
|
|
1559
|
+
setLastSavedBaselines(forComparison, forDirty);
|
|
1560
|
+
this._setDiskBase(forSave, this._ticket());
|
|
1395
1561
|
}
|
|
1396
1562
|
|
|
1397
1563
|
// Announce that a remote morph just landed, so document-level listeners
|
|
@@ -1411,7 +1577,7 @@ class LiveSync {
|
|
|
1411
1577
|
// returns above without reaching here, so nothing can name an author for a
|
|
1412
1578
|
// change this tab never took. Null on every frame the host did not stamp.
|
|
1413
1579
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
1414
|
-
detail: { seq, source: 'peer', by: by || null }
|
|
1580
|
+
detail: { seq, source: 'peer', by: by || null, report }
|
|
1415
1581
|
}));
|
|
1416
1582
|
} finally {
|
|
1417
1583
|
this._log('applyUpdate - morph complete, resuming mutations');
|
|
@@ -1423,22 +1589,22 @@ class LiveSync {
|
|
|
1423
1589
|
this.isPaused = false;
|
|
1424
1590
|
}
|
|
1425
1591
|
|
|
1426
|
-
// Convergence: a
|
|
1427
|
-
// their frame) that exists only in this DOM. Push it out explicitly
|
|
1428
|
-
// morph ran under Mutation.pause, so no autosave was triggered, and a
|
|
1592
|
+
// Convergence: a merge that kept this tab's edits produced a state (our
|
|
1593
|
+
// edits + their frame) that exists only in this DOM. Push it out explicitly
|
|
1594
|
+
// — the morph ran under Mutation.pause, so no autosave was triggered, and a
|
|
1429
1595
|
// pending autosave debounce may already have fired mid-flight. Runs after
|
|
1430
1596
|
// isPaused is lifted so the save's snapshot-ready relay reaches peers.
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1597
|
+
// Only where autosave is on: a manual-save page must never auto-write, so
|
|
1598
|
+
// there the merge stays local, still dirty, until the person saves.
|
|
1599
|
+
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1434
1600
|
}
|
|
1435
1601
|
|
|
1436
1602
|
/**
|
|
1437
1603
|
* Apply an external disk change to this edit-mode tab. Same shape as
|
|
1438
|
-
* _doApplyUpdate with
|
|
1439
|
-
* domain, so
|
|
1440
|
-
* forms flipped live, as boot does on page load);
|
|
1441
|
-
*
|
|
1604
|
+
* _doApplyUpdate with two differences: the incoming document is save
|
|
1605
|
+
* domain, so the merged document is edit-mode ACTIVATED before it applies
|
|
1606
|
+
* (inert attribute forms flipped live, as boot does on page load); and on a
|
|
1607
|
+
* clean apply the
|
|
1442
1608
|
* save baseline advances to a post-morph local comparison capture — true by
|
|
1443
1609
|
* construction, where any wire-derived baseline permanently mismatches
|
|
1444
1610
|
* (token, doctype, transform and parse divergences). A dirty apply leaves
|
|
@@ -1458,85 +1624,72 @@ class LiveSync {
|
|
|
1458
1624
|
Mutation.pause();
|
|
1459
1625
|
pauseGate();
|
|
1460
1626
|
|
|
1461
|
-
let
|
|
1627
|
+
let diverged = false;
|
|
1628
|
+
let typedDuringWait = false;
|
|
1462
1629
|
try {
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
//
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1469
|
-
//
|
|
1470
|
-
//
|
|
1471
|
-
//
|
|
1472
|
-
//
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
const epochAtHold = this._saveEpoch;
|
|
1487
|
-
clearTimeout(this._holdRetryExt);
|
|
1488
|
-
this._holdRetryExt = setTimeout(() => {
|
|
1489
|
-
this._holdRetryExt = null;
|
|
1490
|
-
if (this.isDestroyed || this._pendingExternal != null) return;
|
|
1491
|
-
this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag, by };
|
|
1492
|
-
this._scheduleNextFrame();
|
|
1493
|
-
}, 3000);
|
|
1494
|
-
return;
|
|
1495
|
-
}
|
|
1496
|
-
retainedRoots = protection.entries.length;
|
|
1630
|
+
// Hold the whole frame only when this tab has unsaved edits and no
|
|
1631
|
+
// baseline to merge them against. Lane-guarded like the peer path: a
|
|
1632
|
+
// view-mode tab's gate can still read dirty (a visitor typing into a
|
|
1633
|
+
// [persist] field), and it must take disk frames, never hold them.
|
|
1634
|
+
// Nothing morphs, no baseline moves, and deliberately NO proactive save
|
|
1635
|
+
// (a hold can fire on a manual-save page, which must never auto-write).
|
|
1636
|
+
// The frame's seq watermark has already advanced, so nothing redelivers
|
|
1637
|
+
// it on its own; the retry re-queues it with the epoch captured NOW, so
|
|
1638
|
+
// the drain's epoch check turns an intervening own save into a refetch
|
|
1639
|
+
// of current disk instead of a stale re-apply, and the seq check drops
|
|
1640
|
+
// it once a newer external change supersedes it.
|
|
1641
|
+
if (this.lane === 'live' && pageMaybeDirty() && this._diskBase === null) {
|
|
1642
|
+
console.log('[LiveSync] Holding external change: unsaved local edits and no baseline to merge against');
|
|
1643
|
+
this._setHeld('external', true, null);
|
|
1644
|
+
const epochAtHold = this._saveEpoch;
|
|
1645
|
+
clearTimeout(this._holdRetryExt);
|
|
1646
|
+
this._holdRetryExt = setTimeout(() => {
|
|
1647
|
+
this._holdRetryExt = null;
|
|
1648
|
+
if (this.isDestroyed || this._pendingExternal != null) return;
|
|
1649
|
+
this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag, by };
|
|
1650
|
+
this._scheduleNextFrame();
|
|
1651
|
+
}, 3000);
|
|
1652
|
+
return;
|
|
1497
1653
|
}
|
|
1498
1654
|
|
|
1499
1655
|
this._setHeld('external', false);
|
|
1500
1656
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
await HyperMorph.morph(document.documentElement, newDoc.documentElement, {
|
|
1513
|
-
morphStyle: 'outerHTML',
|
|
1514
|
-
ignoreActiveValue: true,
|
|
1515
|
-
head: { style: 'merge' },
|
|
1516
|
-
scripts: {
|
|
1517
|
-
handle: true,
|
|
1518
|
-
matchMode: 'smart',
|
|
1519
|
-
mergeBase: this.lastHtml,
|
|
1520
|
-
mergeTags: mergeTagRecognizers
|
|
1521
|
-
},
|
|
1522
|
-
key,
|
|
1523
|
-
callbacks: { beforeAttributeUpdated }
|
|
1657
|
+
// All three sides are in the save domain, the file's own inert form: the
|
|
1658
|
+
// bytes this tab last saved or applied, this tab's save capture, and the
|
|
1659
|
+
// file. The merged document is activated for edit mode before it is
|
|
1660
|
+
// applied, exactly as boot would have, so every node, local or incoming,
|
|
1661
|
+
// goes live from its own saved form.
|
|
1662
|
+
const { report, ticket, typedDuringWait: typed } = await this._mergeIncoming(html, null, {
|
|
1663
|
+
base: this._diskBase,
|
|
1664
|
+
captureLocal: () => captureForMerge().saveClone,
|
|
1665
|
+
synthetic: false,
|
|
1666
|
+
extra: { beforeApply: (doc) => activateIncomingDoc(doc.documentElement) },
|
|
1524
1667
|
});
|
|
1668
|
+
diverged = report.localDiverged;
|
|
1669
|
+
typedDuringWait = typed;
|
|
1670
|
+
// The file holds these bytes and this tab has applied them. Dated at the
|
|
1671
|
+
// apply, so a save that captured during the await keeps its newer base.
|
|
1672
|
+
this._setDiskBase(html, ticket);
|
|
1525
1673
|
|
|
1526
1674
|
window.scrollTo(scrollX, scrollY);
|
|
1527
1675
|
|
|
1528
1676
|
// The stamp of section 6, adopted here and nowhere else: this is the moment
|
|
1529
|
-
// the bytes it describes reached this tab. It is taken
|
|
1530
|
-
//
|
|
1531
|
-
// convergence save at the bottom needs a stamp the host
|
|
1532
|
-
// merge is refused and lost.
|
|
1677
|
+
// the bytes it describes reached this tab. It is taken after a merge that
|
|
1678
|
+
// kept local edits too, because the merge still incorporates the disk
|
|
1679
|
+
// bytes, and the convergence save at the bottom needs a stamp the host
|
|
1680
|
+
// will accept or the merge is refused and lost.
|
|
1533
1681
|
//
|
|
1534
1682
|
// A frame with no stamp (an older host, or the content-less fetch fallback,
|
|
1535
1683
|
// which serves bytes nobody stamped) leaves this alone, and the listener in
|
|
1536
1684
|
// etag.js falls back to asking the host.
|
|
1537
1685
|
if (typeof etag === 'string' && etag) recordEtag(etag);
|
|
1538
1686
|
|
|
1539
|
-
if (
|
|
1687
|
+
if (
|
|
1688
|
+
this.lane === 'live' &&
|
|
1689
|
+
!diverged &&
|
|
1690
|
+
!pageMaybeDirty() &&
|
|
1691
|
+
this.unresolvedConflicts.length === 0
|
|
1692
|
+
) {
|
|
1540
1693
|
// Clean apply: the DOM now IS the disk state, so a local comparison
|
|
1541
1694
|
// capture of it is the truthful baseline. The next no-op save skips,
|
|
1542
1695
|
// beforeunload stays quiet. The dirty re-check matters: typing that
|
|
@@ -1552,7 +1705,7 @@ class LiveSync {
|
|
|
1552
1705
|
// does, so it stays in the snapshot domain.
|
|
1553
1706
|
const clone = captureSnapshot({ flushUndo: false });
|
|
1554
1707
|
this.lastHtml = serializeForSync(clone);
|
|
1555
|
-
this._lastIdentityMap = this.
|
|
1708
|
+
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
1556
1709
|
this._applyGen++;
|
|
1557
1710
|
}
|
|
1558
1711
|
|
|
@@ -1562,6 +1715,10 @@ class LiveSync {
|
|
|
1562
1715
|
source: 'disk',
|
|
1563
1716
|
etag: typeof etag === 'string' && etag ? etag : null,
|
|
1564
1717
|
by: by || null,
|
|
1718
|
+
// The bytes now on disk, exactly as they arrived. The source map models them
|
|
1719
|
+
// so this tab's next save copies from the file somebody else just wrote.
|
|
1720
|
+
html,
|
|
1721
|
+
report,
|
|
1565
1722
|
}
|
|
1566
1723
|
}));
|
|
1567
1724
|
} finally {
|
|
@@ -1572,12 +1729,11 @@ class LiveSync {
|
|
|
1572
1729
|
this.isPaused = false;
|
|
1573
1730
|
}
|
|
1574
1731
|
|
|
1575
|
-
// Convergence: disk holds the writer's version, this DOM holds the merge
|
|
1576
|
-
// The baseline was left pre-external, so the
|
|
1577
|
-
//
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
}
|
|
1732
|
+
// Convergence: disk holds the writer's version, this DOM holds the merge
|
|
1733
|
+
// (our edits + their bytes). The baseline was left pre-external, so the
|
|
1734
|
+
// save sees both as changes and writes the merge back. Only where
|
|
1735
|
+
// autosave is on, as in the peer lane.
|
|
1736
|
+
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1581
1737
|
}
|
|
1582
1738
|
|
|
1583
1739
|
/**
|