@panphora/clayjs 1.6.1 → 1.8.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.
@@ -55,11 +55,13 @@ 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, conditionalSaves } from '../core/etag.js';
58
+ import { servedDocumentEtag } from '../core/host-attrs.js';
59
+ import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves, representedEtag, forgetRepresentedEtag } from '../core/etag.js';
59
60
  import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged } from '../lib/dirty-gate.js';
60
61
  import { gestureSeen } from '../lib/user-gesture.js';
61
62
  import { SyncStream } from './stream.js';
62
63
  import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
64
+ import { beginLineageCapture } from './conflict-footprints.js';
63
65
 
64
66
  // What a live-sync merge never reads or touches on any side: editor chrome,
65
67
  // content kept out of the save or the snapshot, frozen regions, and nodes
@@ -121,6 +123,23 @@ const uniqueAuthoredIdentity = (memo) => (el) => {
121
123
 
122
124
  const isIdMap = (m) => !!m && typeof m === 'object' && !Array.isArray(m);
123
125
 
126
+ /**
127
+ * The version a fetched served document names on its own root, or null.
128
+ *
129
+ * The host injects this attribute into the response it builds from one read of
130
+ * the file, so a served-document GET answers which version the body it just
131
+ * returned actually is. Parsed detached: this reads one attribute and never
132
+ * touches the live page, and a body that cannot be parsed simply offers no stamp.
133
+ *
134
+ * @param {string} html
135
+ * @returns {?string}
136
+ */
137
+ function readServedStamp(html) {
138
+ if (typeof html !== 'string' || html === '') return null;
139
+ const parsed = new DOMParser().parseFromString(html, 'text/html');
140
+ return parsed.documentElement?.getAttribute('documentetag') || null;
141
+ }
142
+
124
143
  // The page just took a frame verified clean against its baseline, so it now IS
125
144
  // the file on disk. Both saved baselines move together from one capture: leaving
126
145
  // the dirty baseline behind would make the close warning fire on the frame's own
@@ -324,6 +343,16 @@ class LiveSync {
324
343
  this._holdRetryPeer = null;
325
344
  this._holdRetryExt = null;
326
345
 
346
+ // The startup check: the one fetch this class runs against the page it was
347
+ // itself served, started once by the transport's first cursor and never
348
+ // again. `_servedFetchId` numbers every served-document GET, so a response
349
+ // that arrives after a newer GET was issued can tell it is superseded — only
350
+ // the startup check acts on that, since a repair is re-issued rather than
351
+ // re-read. `_startupRetry` is the single pending re-ask, coalesced so two
352
+ // retries cannot pile up behind one another.
353
+ this._servedFetchId = 0;
354
+ this._startupRetry = null;
355
+
327
356
  // Whether each lane is currently holding. A hold is a safe outcome but a
328
357
  // silent one: without an event the page simply stops updating and nothing
329
358
  // can say why. These make the transitions observable in both directions.
@@ -586,6 +615,12 @@ class LiveSync {
586
615
  clearTimeout(this._holdRetryExt);
587
616
  this._holdRetryPeer = null;
588
617
  this._holdRetryExt = null;
618
+
619
+ // A stopped run's startup check must not act on what it finds: the next
620
+ // fetch, if any, owns the question.
621
+ clearTimeout(this._startupRetry);
622
+ this._startupRetry = null;
623
+ this._servedFetchId++;
589
624
  }
590
625
 
591
626
  /**
@@ -717,6 +752,10 @@ class LiveSync {
717
752
  shared: this._sharedSync,
718
753
  documentURL: window.location.href,
719
754
  lane: this.lane,
755
+ // Only a response that named its own version gives this tab anything to
756
+ // compare against. On a host that served none there is no provenance to
757
+ // check, and the page keeps its pre-1.9.0 startup behavior.
758
+ startupCheck: Boolean(servedDocumentEtag),
720
759
  });
721
760
 
722
761
  this.sse.onopen = () => {
@@ -739,8 +778,22 @@ class LiveSync {
739
778
  } catch {
740
779
  return;
741
780
  }
742
- if (!data || data.resync !== true) return;
743
- console.log('[LiveSync] Server could not replay everything; refetching the document');
781
+ // Two different reasons for one refetch. `resync` is the server saying it
782
+ // could not replay everything between where this client resumed and the
783
+ // baseline it is sending. `startup` is this tab's own check of the page
784
+ // it was served: the file may have changed between that response and this
785
+ // first subscription, and a stamped response is the only thing that can
786
+ // tell. One fetch covers both: a body that names its own version is
787
+ // compared whatever asked for it, and the flag only decides what an
788
+ // unstamped answer may do.
789
+ if (!data || (data.resync !== true && data.startup !== true)) return;
790
+ const repair = data.resync === true;
791
+ const startup = data.startup === true && !repair;
792
+ console.log(
793
+ repair
794
+ ? '[LiveSync] Server could not replay everything; refetching the document'
795
+ : '[LiveSync] Checking the served document against the version this tab was served'
796
+ );
744
797
  // _fetchServedDocument, deliberately, and not _fetchExternalChange: that
745
798
  // one drops a fetch whose seq is at or below the external watermark, and
746
799
  // the cursor baseline routinely is, since it is the server's high-water
@@ -748,7 +801,8 @@ class LiveSync {
748
801
  // resync that skipped itself for being "already seen" would leave the page
749
802
  // permanently stale, which is the exact failure the flag exists to report.
750
803
  this._fetchServedDocument(typeof data.seq === 'number' ? data.seq : undefined, {
751
- repair: true,
804
+ repair,
805
+ startup,
752
806
  });
753
807
  });
754
808
 
@@ -1099,52 +1153,183 @@ class LiveSync {
1099
1153
  * check of its own — callers own that — so it can also re-materialize a
1100
1154
  * frame the epoch check refused (the fetched body is whatever disk holds
1101
1155
  * NOW, which is always safe to apply).
1156
+ *
1157
+ * `startup` marks the one fetch that asks whether the page this tab was served
1158
+ * is still what disk holds. It differs from every other reason to fetch in that
1159
+ * an unchanged answer must do NOTHING: no queue, no apply, no baseline move, no
1160
+ * event, no stamp change. A no-op is not a missed frame here, it is the answer.
1161
+ * A body that names its own version is judged the same way, and a repair that
1162
+ * carries one is no exception.
1163
+ *
1164
+ * @param {number} [seq]
1165
+ * @param {Object} [options]
1166
+ * @param {number} [options.attempt] - Bounded retry count, carried through
1167
+ * every re-ask so a page under constant churn stops asking.
1168
+ * @param {boolean} [options.repair] - The server could not replay everything.
1169
+ * @param {boolean} [options.startup] - The served page's version check.
1102
1170
  */
1103
- _fetchServedDocument(seq, { attempt = 0, repair = false } = {}) {
1171
+ _fetchServedDocument(seq, { attempt = 0, repair = false, startup = false } = {}) {
1172
+ // Content already on its way in is about to change what this check would be
1173
+ // comparing, and its version question is newer than this one. Let it land and
1174
+ // ask again; a check that judged the pre-frame DOM would answer about a page
1175
+ // that no longer exists.
1176
+ if (
1177
+ startup &&
1178
+ (this._morphInFlight || this._pendingHtml != null || this._pendingExternal != null)
1179
+ ) {
1180
+ this._retryStartup(attempt);
1181
+ return;
1182
+ }
1104
1183
  const epoch = this._saveEpoch;
1184
+ const startGen = this._startGen;
1185
+ const seenSeq = this.lastSeenSeq;
1186
+ const applyGen = this._applyGen;
1187
+ const fetchId = ++this._servedFetchId;
1105
1188
  if (repair && typeof seq !== 'number') seq = this._lastExternalSeq;
1106
1189
  fetch(new URL(window.location.href), { cache: 'no-store' })
1107
1190
  .then((response) => (response.ok ? response.text() : null))
1108
1191
  .then((html) => {
1109
1192
  if (this.isDestroyed || html == null) return;
1110
- if (typeof seq === 'number' && seq < this._lastExternalSeq) {
1193
+ if (this._startGen !== startGen) return;
1194
+ const stamp = readServedStamp(html);
1195
+ // A version question is asked by the startup check and by a repair whose
1196
+ // body names its own version. Every other fetch — including an ordinary
1197
+ // fallback whose body happens to carry a stamp — is content, and keeps
1198
+ // the hold/retry behavior the external lane has always had.
1199
+ const versionCheck = startup || (repair && Boolean(stamp));
1200
+ if (versionCheck) {
1201
+ // A newer GET owns this question: whatever it is reading describes a
1202
+ // later moment than this response does. Except for a repair, which is
1203
+ // the only answer a page the server could not replay has: the newer
1204
+ // GET may yet fail, and dropping this body would leave nothing to ask
1205
+ // again. Refetch it at the watermark the page has reached instead,
1206
+ // under the same bounded count the retry always had.
1207
+ if (fetchId !== this._servedFetchId) {
1208
+ if (repair) {
1209
+ this._retryStartup(
1210
+ attempt + 1,
1211
+ { repair, startup },
1212
+ typeof seq === 'number' ? Math.max(seq, this._lastExternalSeq) : seq
1213
+ );
1214
+ }
1215
+ return;
1216
+ }
1217
+ // The page moved while the GET was in flight — an own save, a frame,
1218
+ // or a morph. The comparison this check exists to make is against a
1219
+ // stationary page, so ask again rather than judge a version the tab has
1220
+ // already left. A failed startup GET is the one exception below: it
1221
+ // changes nothing and has nothing to retry toward.
1222
+ if (
1223
+ epoch !== this._saveEpoch ||
1224
+ seenSeq !== this.lastSeenSeq ||
1225
+ applyGen !== this._applyGen ||
1226
+ this._morphInFlight ||
1227
+ this._pendingHtml != null ||
1228
+ this._pendingExternal != null
1229
+ ) {
1230
+ this._retryStartup(attempt + 1, { repair, startup }, seq);
1231
+ return;
1232
+ }
1233
+ if (!stamp) {
1234
+ // No stamp on the body means the host did not answer the version
1235
+ // question, and an answer nobody gave must not be invented: a
1236
+ // startup check keeps exactly the stamp it holds and applies
1237
+ // nothing, fetched or forgotten. A repair still has a page the
1238
+ // server could not replay, so it keeps its unstamped fallback.
1239
+ if (!repair) return;
1240
+ } else if (stamp === representedEtag()) {
1241
+ // The file this tab was served, and nothing to do about it.
1242
+ return;
1243
+ }
1244
+ } else if (typeof seq === 'number' && seq < this._lastExternalSeq) {
1111
1245
  // A newer external change superseded this one, and its own fetch will
1112
1246
  // queue a body. Except for a repair: that one exists because the server
1113
1247
  // said replay cannot fix this page, so if the newer fetch fails there is
1114
1248
  // nothing else coming. Refetch rather than drop the only repair.
1115
1249
  if (repair && attempt < 3) {
1116
- this._fetchServedDocument(this._lastExternalSeq, { attempt: attempt + 1, repair });
1250
+ this._fetchServedDocument(this._lastExternalSeq, { attempt: attempt + 1, repair, startup });
1117
1251
  }
1118
1252
  return;
1119
- }
1120
- if (this._saveEpoch > epoch) {
1253
+ } else if (this._saveEpoch > epoch) {
1121
1254
  // An own save landed while the GET was in flight, so this body may
1122
1255
  // predate it. Save-response order proves nothing about disk-write
1123
1256
  // order — refetch for the newest bytes instead of dropping.
1124
1257
  if (attempt < 3) {
1125
1258
  console.log('[LiveSync] Refetching external change: own save landed mid-fetch');
1126
- this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
1259
+ this._fetchServedDocument(seq, { attempt: attempt + 1, repair, startup });
1127
1260
  }
1128
1261
  return;
1129
1262
  }
1130
- // No stamp: this body came from a GET of the served page, which nobody
1131
- // stamped, so the apply leaves the held stamp alone and etag.js asks the
1132
- // host for a replacement. No author either, for the same reason — a GET
1133
- // answers what disk holds, not who put it there.
1134
- this._pendingExternal = { html, seq, saveEpoch: epoch, etag: null, by: null };
1263
+ // The stamp, when the body carries one, is the version of THESE bytes:
1264
+ // the same claim the navigation made about its own response, read from
1265
+ // the body rather than from discovery, which answers about a later
1266
+ // moment than any response. A body without one keeps the old fallback
1267
+ // — the apply leaves the stamp alone and etag.js asks the host. No
1268
+ // author either way, for the same reason: a GET answers what disk holds,
1269
+ // not who put it there.
1270
+ this._pendingExternal = {
1271
+ html,
1272
+ seq,
1273
+ saveEpoch: epoch,
1274
+ etag: stamp,
1275
+ by: null,
1276
+ // Why this body was fetched, kept beside it so the drain and the apply
1277
+ // can still tell a version check from an ordinary change. A body that
1278
+ // named its own version was judged against this tab's version, and the
1279
+ // drain must go on judging it that way. The epoch refetch below
1280
+ // re-issues a fetch whose caller may have been a repair.
1281
+ fetchOptions: { repair, startup, attempt, versionCheck },
1282
+ // What the check was made against, so the drain can tell whether the
1283
+ // answer it holds still describes the page that asked.
1284
+ startGen,
1285
+ seenSeq,
1286
+ applyGen,
1287
+ fetchId,
1288
+ };
1135
1289
  this._scheduleNextFrame();
1136
1290
  })
1137
1291
  .catch((err) => {
1138
1292
  this._log('External-change fetch failed', err);
1139
1293
  // The watermark already advanced for this seq; leaving it there
1140
1294
  // would drop the change forever. Roll back so a replay or a later
1141
- // duplicate can redeliver it.
1295
+ // duplicate can redeliver it. A startup check has no seq to roll back
1296
+ // and no state to restore: it simply did not happen.
1142
1297
  if (typeof seq === 'number' && this._lastExternalSeq === seq) {
1143
1298
  this._lastExternalSeq = seq - 1;
1144
1299
  }
1145
1300
  });
1146
1301
  }
1147
1302
 
1303
+ /**
1304
+ * Ask the served-document question again, coalesced and bounded.
1305
+ *
1306
+ * A version check that finds the page mid-morph, mid-queue or mid-change cannot
1307
+ * answer, and the answer is the whole point of the fetch, so it re-asks. The
1308
+ * single timer means three arrivals in one frame collapse to one re-ask, and
1309
+ * `attempt` counts only the re-asks that got as far as a GET, so a page that
1310
+ * never settles stops asking rather than looping forever.
1311
+ *
1312
+ * The re-ask carries the options and seq of the fetch that could not answer,
1313
+ * so a re-asked repair is still a repair and still owns its cursor seq.
1314
+ *
1315
+ * @param {number} [attempt]
1316
+ * @param {Object} [options] - The original fetch options.
1317
+ * @param {number} [seq]
1318
+ */
1319
+ _retryStartup(attempt = 0, options = { startup: true }, seq) {
1320
+ if (this.isDestroyed || attempt > 3) return;
1321
+ clearTimeout(this._startupRetry);
1322
+ this._startupRetry = setTimeout(() => {
1323
+ this._startupRetry = null;
1324
+ if (this.isDestroyed) return;
1325
+ if (this._morphInFlight || this._pendingHtml != null || this._pendingExternal != null) {
1326
+ this._retryStartup(attempt, options, seq);
1327
+ return;
1328
+ }
1329
+ this._fetchServedDocument(seq, { ...options, attempt });
1330
+ }, 16);
1331
+ }
1332
+
1148
1333
  /**
1149
1334
  * Apply an update received from the server. Morphs the entire document.
1150
1335
  *
@@ -1199,9 +1384,21 @@ class LiveSync {
1199
1384
  if (this.isDestroyed) return;
1200
1385
 
1201
1386
  const ext = this._pendingExternal;
1387
+ const opts = ext?.fetchOptions || null;
1388
+ // A startup check and a stamped repair are both version checks: each one
1389
+ // judges its body against the version this tab represents, and each one
1390
+ // answers "nothing to do" when the two agree. An ordinary fetch, and an
1391
+ // unstamped repair, keep the pre-existing content behavior.
1392
+ const versionChecked = Boolean(opts?.startup || opts?.versionCheck);
1202
1393
  let runExternal = false;
1203
1394
  if (ext != null) {
1204
- if (this._pendingHtml == null) {
1395
+ if (versionChecked && this._pendingHtml != null) {
1396
+ // A version check and a peer frame have no order to compare: the peer
1397
+ // slot's seq is a subscription cursor, not the version of the bytes it
1398
+ // carries, and the answer is only worth anything once the frame it
1399
+ // would be judging has landed. Drain the peer first and ask again.
1400
+ runExternal = false;
1401
+ } else if (this._pendingHtml == null) {
1205
1402
  runExternal = true;
1206
1403
  } else {
1207
1404
  runExternal = !(
@@ -1215,20 +1412,59 @@ class LiveSync {
1215
1412
  if (runExternal) {
1216
1413
  this._pendingExternal = null;
1217
1414
  // Stale-at-drain checks: a newer external change already superseded
1218
- // this frame, or our own save landed after it was queued.
1415
+ // this frame, our own save landed after it was queued, or the page moved
1416
+ // past what a version check was made against.
1219
1417
  if (typeof ext.seq === 'number' && ext.seq < this._lastExternalSeq) {
1220
- this._log('Dropping superseded external change at drain');
1221
- } else if (ext.saveEpoch !== this._saveEpoch) {
1418
+ if (versionChecked && opts?.repair) {
1419
+ // The repair is the only answer a page the server could not replay
1420
+ // has, and the newer change's own fetch may yet fail. Ask again once
1421
+ // that content has drained instead of dropping it here — at the
1422
+ // current watermark, since the seq this body carried is already
1423
+ // superseded and a re-ask that kept it would only be dropped again.
1424
+ this._retryStartup((opts.attempt || 0) + 1, opts, this._lastExternalSeq);
1425
+ } else {
1426
+ this._log('Dropping superseded external change at drain');
1427
+ }
1428
+ } else if (versionChecked && ext.startGen !== this._startGen) {
1429
+ // The run that asked is gone: this body answers nobody.
1430
+ this._log('Dropping superseded startup check at drain');
1431
+ } else if (versionChecked && ext.fetchId !== this._servedFetchId) {
1432
+ // A newer GET is already re-reading the document, so this body is
1433
+ // superseded — unless it is a repair, the one answer a page the server
1434
+ // could not replay has. The newer GET may yet fail, so ask again at the
1435
+ // watermark the page has reached rather than discard it; the bounded
1436
+ // count stops a page under constant churn.
1437
+ if (opts?.repair) {
1438
+ this._retryStartup((opts.attempt || 0) + 1, opts, this._lastExternalSeq);
1439
+ } else {
1440
+ this._log('Dropping superseded startup check at drain');
1441
+ }
1442
+ } else if (
1443
+ versionChecked &&
1444
+ (ext.saveEpoch !== this._saveEpoch ||
1445
+ ext.seenSeq !== this.lastSeenSeq ||
1446
+ ext.applyGen !== this._applyGen)
1447
+ ) {
1448
+ // The page changed after this check was made. Comparing the version it
1449
+ // holds against a page that has since moved would answer about neither,
1450
+ // so ask again rather than apply a body that may already be obsolete.
1451
+ this._retryStartup((opts.attempt || 0) + 1, opts, ext.seq);
1452
+ } else if (versionChecked && ext.etag != null && ext.etag === representedEtag()) {
1453
+ // Checked once more at the moment of truth, because a frame that applied
1454
+ // while this waited can have brought the very version disk holds.
1455
+ this._log('Startup check found the same version: nothing to apply');
1456
+ } else if (!versionChecked && ext.saveEpoch !== this._saveEpoch) {
1222
1457
  // The epoch moved, but save-response order does not prove disk-write
1223
1458
  // order: the frame's content may still be newer than our save.
1224
1459
  // Refetch the served document — applying what disk holds NOW is
1225
- // always safe — instead of dropping the frame.
1460
+ // always safe — instead of dropping the frame. Mode rides along: a
1461
+ // repair that comes back this way is still a repair.
1226
1462
  console.log('[LiveSync] Refetching external change: own save landed after queue');
1227
- this._fetchServedDocument(ext.seq);
1463
+ this._fetchServedDocument(ext.seq, opts || {});
1228
1464
  } else {
1229
1465
  this._morphInFlight = true;
1230
1466
  try {
1231
- await this._doApplyExternal(ext.html, ext.seq, ext.etag, ext.by);
1467
+ await this._doApplyExternal(ext.html, ext.seq, ext.etag, ext.by, opts);
1232
1468
  } catch (err) {
1233
1469
  console.error('[LiveSync] applyExternal failed:', err);
1234
1470
  } finally {
@@ -1339,6 +1575,19 @@ class LiveSync {
1339
1575
  * @param {object} [lane.extra] - extra mergeDocument options (the disk lane's beforeApply)
1340
1576
  */
1341
1577
  async _mergeIncoming(html, identityMap, { base, baseIdentityMap = null, captureLocal, synthetic, source = 'peer', seq = null, etag = null, extra = {} }) {
1578
+ let lineageCapture;
1579
+ const mergeDocument = (options) => {
1580
+ lineageCapture = beginLineageCapture(conflicts.list());
1581
+ try {
1582
+ const pending = HyperMorph.mergeDocument({ ...options, lineage: lineageCapture.lineage });
1583
+ lineageCapture.returned();
1584
+ return pending;
1585
+ } catch (error) {
1586
+ lineageCapture.invalidate(conflicts.list());
1587
+ lineageCapture.finish(conflicts.list());
1588
+ throw error;
1589
+ }
1590
+ };
1342
1591
  const store = this.identity;
1343
1592
  // A frame may neither write these onto our root nor, by not carrying them,
1344
1593
  // take ours away. Returning false is hyper-morph's veto for both directions.
@@ -1396,7 +1645,7 @@ class LiveSync {
1396
1645
  let localRoot = null;
1397
1646
  let applyId = null;
1398
1647
  if (base == null) {
1399
- pending = HyperMorph.mergeDocument({
1648
+ pending = mergeDocument({
1400
1649
  ...common,
1401
1650
  base: null,
1402
1651
  local: { root: captureLocal(), toLive: originalSnapshotNode },
@@ -1414,7 +1663,7 @@ class LiveSync {
1414
1663
  const localRoot = captureLocal();
1415
1664
  const baseDoc = localRoot.ownerDocument;
1416
1665
  if (baseDoc.documentElement !== localRoot) baseDoc.replaceChild(localRoot, baseDoc.documentElement);
1417
- pending = HyperMorph.mergeDocument({
1666
+ pending = mergeDocument({
1418
1667
  ...common,
1419
1668
  base: baseDoc,
1420
1669
  local: { root: localRoot, toLive: originalSnapshotNode },
@@ -1432,7 +1681,7 @@ class LiveSync {
1432
1681
  domain: source === 'disk' ? 'save' : 'sync',
1433
1682
  });
1434
1683
  try {
1435
- pending = HyperMorph.mergeDocument({
1684
+ pending = mergeDocument({
1436
1685
  ...common,
1437
1686
  base,
1438
1687
  local: { root: localRoot, toLive: originalSnapshotNode },
@@ -1457,7 +1706,12 @@ class LiveSync {
1457
1706
  try {
1458
1707
  report = await pending;
1459
1708
  } catch (err) {
1460
- if (applyId) failApply(applyId, err, { ticket });
1709
+ lineageCapture.invalidate(conflicts.list());
1710
+ try {
1711
+ if (applyId) failApply(applyId, err, { ticket });
1712
+ } finally {
1713
+ lineageCapture.finish(conflicts.list());
1714
+ }
1461
1715
  throw err;
1462
1716
  }
1463
1717
  const typedDuringWait = gateCaptureToken().gen !== token.gen;
@@ -1465,7 +1719,13 @@ class LiveSync {
1465
1719
  // What the frame won over this tab's unsaved edits goes to the ledger with
1466
1720
  // the clone the merge read as "mine". Earlier losses live in the ledger,
1467
1721
  // not in the page's dirty state.
1468
- const conflictIds = applyId ? completeApply(applyId, report.conflicts, { ticket }) : [];
1722
+ let conflictIds;
1723
+ try {
1724
+ lineageCapture.prepareComplete(report, conflicts.list());
1725
+ conflictIds = applyId ? completeApply(applyId, report.conflicts, { ticket }) : [];
1726
+ } finally {
1727
+ lineageCapture.finish(conflicts.list());
1728
+ }
1469
1729
  if (dirty && !report.localDiverged) {
1470
1730
  gateClearIfUnchanged(token);
1471
1731
  }
@@ -1598,9 +1858,16 @@ class LiveSync {
1598
1858
  // above, and deliberately the same moment: the two claims a page makes
1599
1859
  // when it adopts a stamp are "the host stores this version" and "I hold
1600
1860
  // it", and only the second one is this tab's to make.
1601
- if (!(this._acceptedSaveTicket > ticket) && typeof etag === 'string' && etag) {
1602
- recordEtag(etag);
1603
- stamped = true;
1861
+ if (!(this._acceptedSaveTicket > ticket)) {
1862
+ if (typeof etag === 'string' && etag) {
1863
+ recordEtag(etag);
1864
+ stamped = true;
1865
+ } else {
1866
+ // Content nobody stamped: the DOM no longer holds the version this
1867
+ // tab was claiming, so the claim goes. A save accepted since this
1868
+ // frame captured keeps its own newer stamp.
1869
+ forgetRepresentedEtag();
1870
+ }
1604
1871
  }
1605
1872
 
1606
1873
  // Cross-lane baseline: the DOM now holds this frame's content, but the
@@ -1691,7 +1958,7 @@ class LiveSync {
1691
1958
  * holds or a later dirty peer apply misclassifies this frame's content as
1692
1959
  * local edits.
1693
1960
  */
1694
- async _doApplyExternal(html, seq, etag = null, by = null) {
1961
+ async _doApplyExternal(html, seq, etag = null, by = null, fetchOptions = null) {
1695
1962
  this._log(`applyExternal - external disk change (seq=${seq})`);
1696
1963
  this.isPaused = true;
1697
1964
 
@@ -1719,6 +1986,26 @@ class LiveSync {
1719
1986
  if (this.lane === 'live' && pageMaybeDirty() && this._diskBase === null) {
1720
1987
  console.log('[LiveSync] Holding external change: unsaved local edits and no baseline to merge against');
1721
1988
  this._setHeld('external', true, null);
1989
+ // A version check holds nothing: it has no frame to re-queue, and the
1990
+ // body it fetched describes disk as of its GET rather than a version
1991
+ // anyone named. Re-queueing those bytes would apply them over an edit
1992
+ // the person has not saved yet, and could even put a version back over
1993
+ // one a newer peer has already passed. So the hold takes the ordinary
1994
+ // three-second wait and then fetches afresh: whatever disk holds by then
1995
+ // is answered against the page as it stands by then. The hold is not a
1996
+ // retry attempt, so a person who stays dirty never exhausts the bounded
1997
+ // retries; the options ride along so a repair is still a repair.
1998
+ if (fetchOptions?.startup || fetchOptions?.versionCheck) {
1999
+ clearTimeout(this._holdRetryExt);
2000
+ this._holdRetryExt = setTimeout(() => {
2001
+ this._holdRetryExt = null;
2002
+ if (this.isDestroyed) return;
2003
+ const currentSeq =
2004
+ typeof seq === 'number' ? Math.max(seq, this._lastExternalSeq) : seq;
2005
+ this._retryStartup(0, fetchOptions, currentSeq);
2006
+ }, 3000);
2007
+ return;
2008
+ }
1722
2009
  const epochAtHold = this._saveEpoch;
1723
2010
  clearTimeout(this._holdRetryExt);
1724
2011
  this._holdRetryExt = setTimeout(() => {
@@ -1760,12 +2047,18 @@ class LiveSync {
1760
2047
  // bytes, and the convergence save at the bottom needs a stamp the host
1761
2048
  // will accept or the merge is refused and lost.
1762
2049
  //
1763
- // A frame with no stamp (an older host, or the content-less fetch fallback,
1764
- // which serves bytes nobody stamped) leaves this alone, and the listener in
1765
- // etag.js falls back to asking the host.
1766
- if (typeof etag === 'string' && etag) {
1767
- recordEtag(etag);
1768
- stamped = true;
2050
+ // A frame with no stamp (an older host, or the content-less fetch fallback
2051
+ // on a host that does not answer the version question) leaves this alone,
2052
+ // and the listener in etag.js falls back to asking the host. A save
2053
+ // accepted since this frame captured already recorded a newer stamp, and
2054
+ // this body, older than that save, must not rewind it — nor clear it.
2055
+ if (!(this._acceptedSaveTicket > ticket)) {
2056
+ if (typeof etag === 'string' && etag) {
2057
+ recordEtag(etag);
2058
+ stamped = true;
2059
+ } else {
2060
+ forgetRepresentedEtag();
2061
+ }
1769
2062
  }
1770
2063
 
1771
2064
  if (
@@ -3,7 +3,7 @@ const HIDDEN_DELAY = 5000;
3
3
  const PING_INTERVAL = 15000;
4
4
 
5
5
  export class SyncStream extends EventTarget {
6
- constructor(url, { shared = false, documentURL, lane } = {}) {
6
+ constructor(url, { shared = false, documentURL, lane, startupCheck = false } = {}) {
7
7
  super();
8
8
  this.url = url;
9
9
  this.readyState = 0;
@@ -16,7 +16,14 @@ export class SyncStream extends EventTarget {
16
16
  this._since = 0;
17
17
  this._closed = false;
18
18
  this._suspended = false;
19
+ // Two different questions, asked once each at the first subscription and
20
+ // never again: `_repair` is a real reconnect whose replay the host could not
21
+ // retain, and `_startup` is the caller's own check of a page that was served
22
+ // before this stream existed. Only the caller can know whether the response
23
+ // it was served named a version, so the startup check is opt-in and off by
24
+ // default: a stream nobody told about provenance must not manufacture one.
19
25
  this._repair = false;
26
+ this._startup = startupCheck;
20
27
  this._worker = null;
21
28
  this._source = null;
22
29
  this._visibility = () => {
@@ -77,8 +84,11 @@ export class SyncStream extends EventTarget {
77
84
  } else if (data.type === 'cursor') {
78
85
  clearTimeout(this._startTimer);
79
86
  if (Number.isSafeInteger(data.seq)) this._since = Math.max(this._since, data.seq);
80
- this._emit('cursor', JSON.stringify({ ...data, resync: data.resync === true || this._repair }));
87
+ const resync = data.resync === true || this._repair;
88
+ const startup = this._startup;
81
89
  this._repair = false;
90
+ this._startup = false;
91
+ this._emit('cursor', JSON.stringify({ ...data, resync, startup }));
82
92
  } else if (data.type === 'frame' && typeof data.data === 'string') {
83
93
  this._remember(data.data);
84
94
  this._emit('message', data.data);
@@ -111,9 +121,12 @@ export class SyncStream extends EventTarget {
111
121
  if (this._source !== source) return;
112
122
  this.readyState = 1;
113
123
  this._emit('open');
114
- if (this._repair) {
124
+ if (this._repair || this._startup) {
125
+ const resync = this._repair;
126
+ const startup = this._startup;
115
127
  this._repair = false;
116
- this._emit('cursor', JSON.stringify({ resync: true }));
128
+ this._startup = false;
129
+ this._emit('cursor', JSON.stringify({ resync, startup }));
117
130
  }
118
131
  };
119
132
  source.onerror = () => {
@@ -72,8 +72,10 @@ function protectIcon(svg, size) {
72
72
  `fill:${attr('fill', svg.getAttribute('fill') ?? 'currentColor')}`,
73
73
  `stroke:${attr('stroke', svg.getAttribute('stroke') ?? 'none')}`,
74
74
  ];
75
- if (el.hasAttribute('stroke-width')) rules.push(`stroke-width:${el.getAttribute('stroke-width')}`);
76
- if (el.hasAttribute('stroke-linecap')) rules.push(`stroke-linecap:${el.getAttribute('stroke-linecap')}`);
75
+ for (const prop of ['stroke-width', 'stroke-linecap', 'stroke-linejoin']) {
76
+ const value = el.getAttribute(prop) ?? svg.getAttribute(prop);
77
+ if (value !== null) rules.push(`${prop}:${value}`);
78
+ }
77
79
  if (el.localName === 'path' && el.hasAttribute('d')) rules.push(`d:path("${el.getAttribute('d')}")`);
78
80
  style(el, rules);
79
81
  }
@@ -84,6 +86,7 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
84
86
  const size = small ? SIZE.small : SIZE.normal;
85
87
  const reducedMotion = media('(prefers-reduced-motion: reduce)');
86
88
  const forced = media('(forced-colors: active)');
89
+ const coarse = media('(pointer: coarse)');
87
90
  const flags = { hovered: false, pressed: false, focusVisible: false, disabled: false };
88
91
  const pinned = new Map();
89
92
 
@@ -103,6 +106,7 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
103
106
  }
104
107
  if (flags.focusVisible) rules.push(...RULES.focus);
105
108
  if (forced) rules.push(...MEDIA.forcedColors);
109
+ if (coarse) rules.push(...MEDIA.coarseButton);
106
110
  rules.push(...extra);
107
111
  for (const [prop, value] of pinned) rules.push(`${prop}:${value}`);
108
112
  restyle(b, rules);
@@ -234,6 +238,7 @@ export function bevelText(tag, rules = [], text) {
234
238
  export function paintInput(el, { rules = [] } = {}) {
235
239
  const flags = { hovered: false, focused: false };
236
240
  const forced = media('(forced-colors: active)');
241
+ const coarse = media('(pointer: coarse)');
237
242
  runtime(el);
238
243
  function applyState() {
239
244
  const height = el.localName === 'textarea' ? el.style.getPropertyValue('height') : '';
@@ -242,6 +247,7 @@ export function paintInput(el, { rules = [] } = {}) {
242
247
  if (flags.focused) list.push(...RULES.inputFocus);
243
248
  if (forced) list.push(...MEDIA.forcedColors);
244
249
  list.push(...rules);
250
+ if (coarse) list.push(...MEDIA.coarseInput);
245
251
  if (height) list.push(`height:${height}`);
246
252
  restyle(el, list);
247
253
  }