@panphora/clayjs 1.1.0 → 1.3.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.
Files changed (43) hide show
  1. package/README.md +4 -1
  2. package/THIRD-PARTY-NOTICES.md +10 -0
  3. package/dist/clay.standalone.js +21751 -13942
  4. package/entries/clay-data.js +1 -1
  5. package/package.json +7 -2
  6. package/packed-contract.json +17 -0
  7. package/src/attrs/save-freeze.js +10 -18
  8. package/src/core/admin-contenteditable.js +5 -6
  9. package/src/core/etag.js +17 -4
  10. package/src/core/host-attrs.js +52 -2
  11. package/src/core/is-edit-mode.js +14 -5
  12. package/src/core/persist.js +5 -10
  13. package/src/core/save-conflict-notice.js +22 -27
  14. package/src/core/save-core.js +326 -26
  15. package/src/core/save.js +19 -4
  16. package/src/core/snapshot.js +148 -15
  17. package/src/core/source-map.js +817 -0
  18. package/src/core/stale-host-notice.js +95 -0
  19. package/src/core/unsaved-warning.js +3 -0
  20. package/src/dom/dom-helpers.js +5 -1
  21. package/src/lib/content-dom.js +108 -0
  22. package/src/lib/hostile-css.js +49 -0
  23. package/src/lib/mutation.js +26 -3
  24. package/src/lib/region-capabilities.js +69 -0
  25. package/src/lib/region-policy.js +18 -13
  26. package/src/lib/root-attrs.js +52 -13
  27. package/src/loader-logic.js +25 -4
  28. package/src/loader.js +4 -0
  29. package/src/plugins/demo.js +3 -0
  30. package/src/plugins/sortable.js +6 -1
  31. package/src/plugins/source.js +326 -0
  32. package/src/plugins/wire.js +248 -47
  33. package/src/sync/live-sync.js +275 -63
  34. package/src/sync/presence.js +303 -0
  35. package/src/sync/section-notice.js +230 -0
  36. package/src/sync/splice-merge.js +7 -10
  37. package/src/sync/stream.js +190 -0
  38. package/src/vendor/hyper-morph.vendor.js +2 -2
  39. package/src/vendor/hyper-undo.vendor.js +1 -1
  40. package/src/vendor/hypercms.vendor.js +438 -45
  41. package/src/vendor/parse5.vendor.js +3 -0
  42. package/src/vendor/quickcrop.vendor.js +1 -1
  43. package/src/vendor/richclay.vendor.js +22 -15
@@ -10,7 +10,8 @@
10
10
  import { isEditMode } from "./is-edit-mode.js";
11
11
  import { consumeUserDriven, consumeExplicitSave, markUserDriven } from "../lib/user-gesture.js";
12
12
  import { saveToken } from "./host-attrs.js";
13
- import { lastSeenEtag, conditionalSaves, recordEtag, seedEtag } from "./etag.js";
13
+ import { lastSeenEtag, conditionalSaves, recordEtag } from "./etag.js";
14
+ import { hostMeta } from "./host-meta.js";
14
15
  import {
15
16
  getPageContents,
16
17
  onSnapshot,
@@ -25,6 +26,17 @@ let saveInProgress = false;
25
26
  const SAVE_PATH = '/_/save';
26
27
  const SAVE_TIMEOUT_MS = 12000;
27
28
 
29
+ // Listeners for "the host took these exact bytes". This is the only place in the
30
+ // library that knows both the body that went out and that the host accepted it, and
31
+ // the two together are what make the bytes a fact about the file rather than a guess:
32
+ // a capture says what we would send, a success says a write happened, and only their
33
+ // conjunction says what is on disk. The source map uses it to re-model, so the next
34
+ // save is measured from the file as it now is.
35
+ //
36
+ // Not a DOM event, deliberately. The whole document rides in the argument, and a
37
+ // CustomEvent would put it on a bus any script on the page can listen to.
38
+ const saveAcceptedHooks = [];
39
+
28
40
  /**
29
41
  * Check if a save is currently in progress.
30
42
  * @returns {boolean}
@@ -33,6 +45,29 @@ export function isSaveInProgress() {
33
45
  return saveInProgress;
34
46
  }
35
47
 
48
+ /**
49
+ * Run a callback with the bytes the host just accepted.
50
+ *
51
+ * Called once per accepted save, after the response, with the exact body that was
52
+ * sent. Never called for a refused, failed or skipped save, and never in test mode,
53
+ * where nothing reached a host.
54
+ *
55
+ * @param {Function} callback - Receives the accepted HTML string
56
+ */
57
+ export function onSaveAccepted(callback) {
58
+ saveAcceptedHooks.push(callback);
59
+ }
60
+
61
+ function notifySaveAccepted(html) {
62
+ for (const hook of saveAcceptedHooks) {
63
+ try {
64
+ hook(html);
65
+ } catch (err) {
66
+ console.error('clayjs: onSaveAccepted hook failed:', err);
67
+ }
68
+ }
69
+ }
70
+
36
71
  // =============================================================================
37
72
  // RE-EXPORTS FROM SNAPSHOT (for backwards compat)
38
73
  // =============================================================================
@@ -61,6 +96,93 @@ function successResult(data) {
61
96
  };
62
97
  }
63
98
 
99
+ // =============================================================================
100
+ // THE ATTEMPT ID (spec §6, `receipts`)
101
+ // =============================================================================
102
+ // A save that times out leaves two questions open at once: did our write land,
103
+ // and did somebody else write. The client cannot answer either from the outside.
104
+ // Guessing "it was me" is what the old reconcile-on-timeout did, and when the
105
+ // guess was wrong it adopted a stamp describing a peer's bytes this tab had never
106
+ // seen, so the next save overwrote them with no refusal and no notice. Refusing to
107
+ // guess is safe but shows a person a conflict bar for a few seconds of bad wifi.
108
+ //
109
+ // So this tab stamps every attempt with an opaque id and asks the host the one
110
+ // question only the host can answer: are the bytes you are storing the ones this
111
+ // save sent? A host that answers turns an indeterminate save into a determinate
112
+ // one, and the person sees nothing at all.
113
+ //
114
+ // The ids this tab has sent, newest last. A 412 carrying one of them is the host
115
+ // saying "your OWN earlier save is what moved this document", which is a late
116
+ // duplicate and not a conflict with anybody. Bounded because it is only ever
117
+ // searched for a recent send.
118
+ const sentIds = [];
119
+ const SENT_ID_MEMORY = 8;
120
+
121
+ // The attempt whose answer never arrived, or null. It holds only what is needed to
122
+ // ask about it later: which id it carried, and which stamp it was sent against.
123
+ //
124
+ // It survives ONLY while the question is genuinely open. A host that answers, in
125
+ // either direction, closes it, which is what keeps the conflict bar from claiming
126
+ // a refusal might be the person's own save when the host has already said it is
127
+ // not. An unreachable host leaves it set, and that is the honest old behaviour:
128
+ // the stamp is kept, the next save is refused rather than accepted, and the notice
129
+ // says so.
130
+ let unknownAttempt = null;
131
+
132
+ /** True while an earlier save may or may not have landed (spec §7). */
133
+ export function saveFateIsUnknown() {
134
+ return unknownAttempt !== null;
135
+ }
136
+
137
+ /**
138
+ * A fresh id for one save attempt.
139
+ *
140
+ * Uniqueness per attempt is the whole property: two tabs sharing an id would each
141
+ * read the other's receipt as proof of their own write. `getRandomValues` into a
142
+ * zeroed array is the failure that would do exactly that, silently, so a missing
143
+ * crypto implementation falls back to something random rather than to zeros.
144
+ */
145
+ function mintSaveId() {
146
+ const uuid = window.crypto?.randomUUID?.();
147
+ if (uuid) return uuid;
148
+ if (window.crypto?.getRandomValues) {
149
+ const bytes = new Uint8Array(16);
150
+ window.crypto.getRandomValues(bytes);
151
+ return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
152
+ }
153
+ // Three segments, not one: §6 asks for at least 128 bits, and one `Math.random()`
154
+ // truncated to ten base-36 characters carries about 52. A timestamp is not entropy,
155
+ // so it cannot make up the difference between two tabs minting in the same
156
+ // millisecond, which is the collision that matters.
157
+ const segment = () => Math.random().toString(36).slice(2, 12).padEnd(10, "0");
158
+ return `${Date.now().toString(36)}-${segment()}${segment()}${segment()}`;
159
+ }
160
+
161
+ function rememberSentId(saveId) {
162
+ sentIds.push(saveId);
163
+ if (sentIds.length > SENT_ID_MEMORY) sentIds.shift();
164
+ }
165
+
166
+ /**
167
+ * Did this tab send this id at all?
168
+ *
169
+ * The weaker of the two questions asked of a receipt, and it belongs only to the
170
+ * late-duplicate rule: a 412 naming ANY id this tab sent means this tab's own
171
+ * earlier save is what moved the document, which is enough to adopt that stamp and
172
+ * send again, because sending again is all that follows. It is never enough to
173
+ * conclude that a particular save landed. That needs identity with that save's own
174
+ * id, which is what recoverUnknownSave asks.
175
+ */
176
+ function sentByThisTab(saveId) {
177
+ return typeof saveId === "string" && saveId !== "" && sentIds.includes(saveId);
178
+ }
179
+
180
+ /** Test-only: forget every id and any open question. */
181
+ export function resetSaveAttempts() {
182
+ sentIds.length = 0;
183
+ unknownAttempt = null;
184
+ }
185
+
64
186
  function errorResult(err) {
65
187
  const timedOut = err.name === 'AbortError';
66
188
  // Spec §6: a 412 is a REFUSED save, not a failed one. The host wrote nothing and
@@ -68,9 +190,13 @@ function errorResult(err) {
68
190
  // are still on this page. Reporting that as 'error' would give the one outcome
69
191
  // where nothing went wrong the one severity that means something did, and would
70
192
  // put it through the same retry-and-toast path as a dead server.
71
- // The status is authoritative (§3), and `code` is honoured too because a proxy
72
- // can answer 412 on the host's behalf with no body at all.
73
- const conflicted = !timedOut && (err.status === 412 || err.code === 'conflict');
193
+ // The status is authoritative (§3), so the 412 alone decides this. Reading `code`
194
+ // as well let any other status claim a conflict, and it bought nothing: a proxy
195
+ // answering 412 with no body carries no code to read, so the fallback never
196
+ // covered the case it was written for. What it did cover was htmlclay's
197
+ // truncation refusal, a 409 that lifts itself within a second, which suspended
198
+ // autosave over a condition that had already cleared.
199
+ const conflicted = !timedOut && err.status === 412;
74
200
  return {
75
201
  ok: false,
76
202
  msg: timedOut ? 'Server not responding' : (err.message || 'Save failed'),
@@ -78,6 +204,10 @@ function errorResult(err) {
78
204
  // so this reports "we do not know" rather than asserting something false.
79
205
  msgType: timedOut ? 'unknown' : (conflicted ? 'conflict' : 'error'),
80
206
  code: timedOut ? 'timeout' : (conflicted ? 'conflict' : (err.code ?? null)),
207
+ changedBy: conflicted ? (err.changedBy ?? null) : null,
208
+ // A refusal that may be answering this tab's own timed-out write. Only the
209
+ // notice uses it, and only to word itself; nothing decides anything on it.
210
+ afterTimeout: conflicted && unknownAttempt !== null,
81
211
  etag: null
82
212
  };
83
213
  }
@@ -122,9 +252,11 @@ function skippedResult(msg) {
122
252
  * @param {string} content - HTML to save
123
253
  * @param {boolean} userDriven - Whether a human gesture is behind this save
124
254
  * @param {AbortSignal} signal
255
+ * @param {string} saveId - this attempt's §6 receipt id
256
+ * @param {?string} etag - the stamp to send as If-Match, or null
125
257
  * @returns {{url: string, options: Object}}
126
258
  */
127
- function buildSaveRequest(content, userDriven, signal) {
259
+ function buildSaveRequest(content, userDriven, signal, saveId, etag) {
128
260
  const token = saveToken();
129
261
  const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
130
262
  const options = {
@@ -138,25 +270,36 @@ function buildSaveRequest(content, userDriven, signal) {
138
270
  // Spec §9's name for the provenance bit. `X-Hyperclay-User-Driven` was the
139
271
  // pre-spec spelling; every host reads Save-Trigger first and falls back to
140
272
  // it, so stored documents running an older client keep working.
141
- 'Save-Trigger': userDriven ? 'user' : 'auto'
273
+ 'Save-Trigger': userDriven ? 'user' : 'auto',
274
+ // Spec §6 (`receipts`): which attempt this is. Sent to every host, because a
275
+ // host that does not implement receipts ignores an unknown header, and
276
+ // gating it on discovery would mean holding the save until an async lookup
277
+ // resolved. Nothing is inferred from the header being accepted: only a host
278
+ // that ANSWERS with the id has said anything.
279
+ 'Save-ID': saveId
142
280
  },
143
281
  body: content
144
282
  };
145
283
 
146
- // Spec §6: the stamp this tab last saw, so a host that advertises `conditional`
147
- // can refuse rather than overwrite a version nobody here has read.
148
- //
149
- // Two gates, and both are the point. The capability must have been announced by
150
- // name, because §5 forbids inferring one any other way, and a host that never
151
- // promised to honour If-Match may do anything at all with it. And a stamp must
152
- // actually be held: a host reads this header's PRESENCE, so an empty value is
153
- // not a softer version of the request, it is a save asking to be refused.
154
- const etag = lastSeenEtag();
155
- if (conditionalSaves() && etag) options.headers['If-Match'] = etag;
284
+ if (etag) options.headers['If-Match'] = etag;
156
285
 
157
286
  return { url: resolveSaveUrl(path), options };
158
287
  }
159
288
 
289
+ /**
290
+ * The stamp to send as If-Match, or null for a save that goes out unconditional.
291
+ *
292
+ * Two gates, and both are the point. The capability must have been announced by
293
+ * name, because §5 forbids inferring one any other way, and a host that never
294
+ * promised to honour If-Match may do anything at all with it. And a stamp must
295
+ * actually be held: a host reads the header's PRESENCE, so an empty value is not a
296
+ * softer version of the request, it is a save asking to be refused.
297
+ */
298
+ function stampToSend() {
299
+ const etag = lastSeenEtag();
300
+ return conditionalSaves() && etag ? etag : null;
301
+ }
302
+
160
303
  /**
161
304
  * The absolute URL a save goes to.
162
305
  *
@@ -197,7 +340,7 @@ function resolveSaveUrl(path) {
197
340
  * @param {string} content - HTML to save
198
341
  * @returns {Promise<Object>} The server's response body
199
342
  */
200
- function sendSave(content) {
343
+ function sendOnce(content, saveId, etag) {
201
344
  const controller = new AbortController();
202
345
  const timeoutId = setTimeout(() => controller.abort(), SAVE_TIMEOUT_MS);
203
346
 
@@ -210,7 +353,8 @@ function sendSave(content) {
210
353
  const gestureDriven = consumeUserDriven();
211
354
  const explicitlyAsked = consumeExplicitSave();
212
355
  const userDriven = gestureDriven || explicitlyAsked;
213
- const { url, options } = buildSaveRequest(content, userDriven, controller.signal);
356
+ const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag);
357
+ rememberSentId(saveId);
214
358
 
215
359
  return fetch(url, options)
216
360
  .then(res => res.text().then(text => {
@@ -226,24 +370,35 @@ function sendSave(content) {
226
370
  const error = new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
227
371
  error.code = data.code ?? null;
228
372
  error.status = res.status;
373
+ // Spec §6: a conflict may name what moved the document. Only the host can
374
+ // know, and it often cannot, so this rides through as-is and the notice
375
+ // falls back to a phrase that is true either way.
376
+ error.changedBy = data.changedBy ?? null;
377
+ // §6: the stamp of the bytes that caused the refusal, and the receipt for
378
+ // them. Both are what let a refusal be recovered from instead of merely
379
+ // reported: the stamp is what the retry has to carry, and the receipt says
380
+ // whether the document was moved by this tab's own earlier save.
381
+ error.etag = data.etag ?? null;
382
+ error.saveId = data.saveId ?? null;
229
383
  throw error;
230
384
  }
231
385
  // The one place a stamp is ever learned from a save (§6). A response with no
232
386
  // etag clears it rather than keeping the old one, because the write we just
233
387
  // made means any stamp held here describes bytes the host no longer stores.
234
388
  recordEtag(data.etag ?? null);
389
+ // A write the host answered: whatever an earlier timeout left uncertain,
390
+ // this document's version is known again.
391
+ unknownAttempt = null;
235
392
  return data;
236
393
  }))
237
394
  .catch(err => {
238
395
  console.error('Failed to save page:', err);
239
- // Spec §7: a timeout is INDETERMINATE, so this write may well have landed,
240
- // and the stamp held here may already describe bytes the host has replaced.
241
- // Reconcile against the host before anything retries, or a save that times
242
- // out and lands is followed by a 412 the person cannot explain and did not
243
- // cause. The only save that can have moved the document inside that window
244
- // is almost always this one, so taking the host's current stamp is taking
245
- // back our own.
246
- if (err.name === 'AbortError') seedEtag({ fresh: true });
396
+ // Spec §7: a timeout is INDETERMINATE. The stamp is deliberately NOT
397
+ // reconciled against the host here; what this records is the question, so
398
+ // recovery below can ask it. The old code adopted the host's current stamp
399
+ // on the guess that our own write was what moved the document, which was
400
+ // wrong exactly when somebody else wrote.
401
+ if (err.name === 'AbortError') unknownAttempt = { saveId, etag };
247
402
  // The save never landed: re-arm the user-driven bit so the next (retry)
248
403
  // save still reports the human gesture instead of reading as background.
249
404
  if (userDriven) markUserDriven();
@@ -254,6 +409,150 @@ function sendSave(content) {
254
409
  });
255
410
  }
256
411
 
412
+ /**
413
+ * Ask the host what became of the attempt whose answer never arrived (spec §7).
414
+ *
415
+ * One question, one round trip, and only two things to do with the answer:
416
+ *
417
+ * - the host's receipt is ours, so the bytes it stores are the ones this save
418
+ * sent. The save landed. Adopt the stamp for those bytes and report success:
419
+ * the person sees a save that worked, because one did.
420
+ * - anything else, including a host that keeps no receipts at all: send the save
421
+ * again, under the ORIGINAL If-Match.
422
+ *
423
+ * The second case looks like it needs to know more than it does, and it does not,
424
+ * because the stamp is what makes it safe. If our write did land, the stamp is now
425
+ * stale and the re-send is REFUSED, carrying the host's receipt for the bytes that
426
+ * refused it; that receipt is ours, and the late-duplicate rule in sendSave turns
427
+ * it back into a finished save without a word to anybody. If somebody else wrote,
428
+ * the same refusal carries their bytes and becomes an honest conflict. And if
429
+ * nothing landed, the stamp still matches and the save simply goes through.
430
+ *
431
+ * So there is no branch here that guesses. Every outcome is decided by a host
432
+ * answering a conditional request, which is the one thing in this protocol that
433
+ * cannot be wrong.
434
+ *
435
+ * A host that cannot be reached leaves the question open, which is the older
436
+ * behaviour and the honest one: the stamp is kept, the next save is refused rather
437
+ * than accepted, and the notice says the refusal may be answering this tab's own
438
+ * timed-out save.
439
+ *
440
+ * @returns {Promise<{landed: ?Object, resend: boolean}>}
441
+ */
442
+ async function recoverUnknownSave() {
443
+ const attempt = unknownAttempt;
444
+ if (!attempt) return { landed: null, resend: false };
445
+
446
+ const meta = await hostMeta({ fresh: true });
447
+ const doc = meta.document;
448
+ const stored = typeof doc?.etag === 'string' && doc.etag ? doc.etag : null;
449
+ if (!stored) return { landed: null, resend: false };
450
+
451
+ // THIS attempt's id, and no other. A receipt is proof about the save that carried
452
+ // that exact id, so an earlier save of this same tab naming itself proves only
453
+ // that the earlier save landed, which is a thing already known and says nothing
454
+ // about this one. Matching any id this tab has sent turns the most ordinary
455
+ // failure there is, a request that never left the browser, into a reported
456
+ // success: the tab shows Saved, advances its baselines, drops the close warning,
457
+ // and the bytes are on no disk anywhere. Membership is the right test one line
458
+ // down in sendSave, where a 412 only ever leads to another conditional send.
459
+ if (doc.saveId && doc.saveId === attempt.saveId) {
460
+ // Proof, not inference: the host is saying the bytes it stores are the stored
461
+ // form of the body THIS save sent. §6 lets a client adopt a stamp on exactly that.
462
+ recordEtag(stored);
463
+ unknownAttempt = null;
464
+ return { landed: { msg: 'Saved', msgType: 'success', etag: stored }, resend: false };
465
+ }
466
+
467
+ // Positive proof, and nothing weaker, closes the question. A non-empty id that is
468
+ // not ours names another save as the author of these bytes. An ABSENT id proves
469
+ // nothing at all: §6 lets a host lose its pair to a restart or an eviction and stay
470
+ // conforming, so a host that keeps receipts can report none for bytes this tab
471
+ // really did write. Reading absence as "somebody else wrote" makes the notice tell
472
+ // a person their own save was somebody else's change.
473
+ if (doc.saveId) unknownAttempt = null;
474
+
475
+ // The re-send carries the stamp the ORIGINAL save carried, which is the normative
476
+ // rule and not a detail: the stamp this tab holds is mutable and moves for reasons
477
+ // that have nothing to do with this save, because a disk-sourced live-sync frame
478
+ // records the stamp of the bytes it applied. Re-sending under that one is a write
479
+ // conditional on a version this save was never judged against, and the host accepts
480
+ // it, replacing bytes the person has just been shown with bytes captured before
481
+ // they arrived.
482
+ //
483
+ // No stamp at all means there is nothing to re-send under, so nothing is re-sent.
484
+ // An unconditional recovery write has no comparison to refuse it and would replace
485
+ // whatever landed while this tab was waiting, which is the loss this whole
486
+ // capability exists to prevent.
487
+ return { landed: null, resend: !!attempt.etag, etag: attempt.etag };
488
+ }
489
+
490
+ /**
491
+ * Send one save, and see it through.
492
+ *
493
+ * Two things can happen that are not the save's own outcome, and both are handled
494
+ * here rather than reported:
495
+ *
496
+ * - the request timed out, so the host is asked what became of it,
497
+ * - the host refused with a receipt this tab recognises, which means its own
498
+ * earlier save is what moved the document. That is a late duplicate, not a
499
+ * conflict with anybody: take the stamp the refusal handed back and finish
500
+ * the save that was refused.
501
+ *
502
+ * Each recovery sends at most one further request, so a save can never loop.
503
+ *
504
+ * @param {string} content - HTML to save
505
+ * @returns {Promise<Object>} The server's response body
506
+ */
507
+ async function sendSave(content) {
508
+ // The re-send below is a RETRY of this attempt, so it carries this same id:
509
+ // whichever of the two requests the host ends up storing, its receipt then
510
+ // answers for this save. A fresh id on the re-send would leave the first
511
+ // request's landing unprovable.
512
+ let saveId = mintSaveId();
513
+ let etag = stampToSend();
514
+ // Each recovery runs at most once, and they are counted apart because they are
515
+ // different things. Re-sending after a timeout is worth doing once: a second
516
+ // timeout says the host is not answering this document in time, and sending a
517
+ // whole document a third time will not change that. Resolving a late duplicate
518
+ // is fast and conclusive, so it gets its own turn regardless.
519
+ let reSent = false;
520
+ let duplicateResolved = false;
521
+ for (;;) {
522
+ try {
523
+ return await sendOnce(content, saveId, etag);
524
+ } catch (err) {
525
+ if (err.name === 'AbortError') {
526
+ if (reSent) throw err;
527
+ const { landed, resend, etag: original } = await recoverUnknownSave();
528
+ if (landed) return landed;
529
+ if (!resend) throw err;
530
+ // Explicitly the original, never `stampToSend()` again.
531
+ etag = original;
532
+ reSent = true;
533
+ continue;
534
+ }
535
+
536
+ if (err.status === 412 && !duplicateResolved && sentByThisTab(err.saveId)) {
537
+ // Our own earlier save is what the host is refusing us against: a late
538
+ // duplicate, not a conflict with anybody. Adopt the stamp for those bytes
539
+ // and send the current ones on top of it. The retry is still conditional,
540
+ // so anything that arrives in between still refuses it.
541
+ recordEtag(err.etag ?? null);
542
+ unknownAttempt = null;
543
+ saveId = mintSaveId();
544
+ // A new attempt, deliberately on the base the host just proved is this tab's
545
+ // own work, rather than on the stamp the refused attempt carried.
546
+ etag = stampToSend();
547
+ duplicateResolved = true;
548
+ continue;
549
+ }
550
+
551
+ throw err;
552
+ }
553
+ }
554
+ }
555
+
257
556
  // =============================================================================
258
557
  // SAVE FUNCTIONS
259
558
  // =============================================================================
@@ -304,6 +603,7 @@ export function saveHtml(html, callback = () => {}) {
304
603
  // is busy by the save that just finished.
305
604
  .then(result => {
306
605
  saveInProgress = false;
606
+ if (result.ok) notifySaveAccepted(html);
307
607
  done(result);
308
608
  });
309
609
  });
package/src/core/save.js CHANGED
@@ -17,7 +17,8 @@ import {
17
17
  getPageContents,
18
18
  replacePageWith as replacePageWithCore,
19
19
  addDocumentTransform,
20
- isSaveInProgress
20
+ isSaveInProgress,
21
+ saveFateIsUnknown
21
22
  } from "./save-core.js";
22
23
  import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
23
24
  import { seedEtag } from "./etag.js";
@@ -54,8 +55,9 @@ let savingTimeout = null;
54
55
  * @param {string} state - One of: 'saving', 'saved', 'offline', 'error', 'conflict'
55
56
  * @param {string} msg - Optional message (e.g., error details)
56
57
  * @param {string} msgType - Optional severity from the server (e.g., 'warning')
58
+ * @param {Object} [extra] - Extra detail fields for the event (e.g. `changedBy`)
57
59
  */
58
- function setSaveState(state, msg = '', msgType = '') {
60
+ function setSaveState(state, msg = '', msgType = '', extra = null) {
59
61
  if (savingTimeout) {
60
62
  clearTimeout(savingTimeout);
61
63
  savingTimeout = null;
@@ -64,7 +66,7 @@ function setSaveState(state, msg = '', msgType = '') {
64
66
  document.documentElement.setAttribute('savestatus', state);
65
67
 
66
68
  const event = new CustomEvent(`clay:save-${state}`, {
67
- detail: { msg, msgType, timestamp: Date.now() }
69
+ detail: { msg, msgType, timestamp: Date.now(), ...(extra || {}) }
68
70
  });
69
71
  document.dispatchEvent(event);
70
72
  }
@@ -261,7 +263,10 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken) {
261
263
  releaseConflictHold();
262
264
  } else if (result.msgType === 'conflict') {
263
265
  holdForConflict();
264
- setSaveState('conflict', result.msg, result.msgType);
266
+ setSaveState('conflict', result.msg, result.msgType, {
267
+ changedBy: result.changedBy ?? null,
268
+ afterTimeout: result.afterTimeout === true,
269
+ });
265
270
  } else if (result.msgType !== 'skipped') {
266
271
  if (!navigator.onLine) {
267
272
  setSaveState('offline', result.msg);
@@ -274,8 +279,18 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken) {
274
279
  // Run the save that arrived while this one was in flight. savePage does its own
275
280
  // dirty check, so if nothing actually changed it resolves 'skipped' and stops:
276
281
  // this cannot spin.
282
+ //
283
+ // Not while the last save's fate is still unknown, which happens only when the
284
+ // save timed out AND the host could not be asked what became of it. Sending then
285
+ // is sending into a question: the host is unreachable, so the save is most likely
286
+ // to time out too, and if it does reach a host that took the first write it is
287
+ // refused, which puts a conflict bar on screen seconds after a person typed,
288
+ // unprompted, over what is really a network problem. The queued state is KEPT, not
289
+ // dropped, so the newer bytes still go out on the next save; the page is still
290
+ // dirty, so an edit or a close warning will produce one.
277
291
  function drainPendingSave() {
278
292
  if (!pendingSave) return;
293
+ if (saveFateIsUnknown()) return;
279
294
  pendingSave = false;
280
295
  savePage();
281
296
  }