@panphora/clayjs 1.11.1 → 1.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,7 @@ import { onSnapshot, addDocumentTransform } from "../core/snapshot.js";
20
20
  import { isEditMode } from "../core/is-edit-mode.js";
21
21
  import { FREEZE_SELECTOR } from "../lib/region-policy.js";
22
22
  import { ROUTE_ACTIVE_ATTR } from "../lib/root-attrs.js";
23
+ import { stripEphemeral } from "../lib/ephemeral.js";
23
24
  import Mutation from "../lib/mutation.js";
24
25
 
25
26
  const originals = new WeakMap();
@@ -30,12 +31,14 @@ function log(...args) {
30
31
  if (saveFreeze.debug) console.log('[save-freeze]', ...args);
31
32
  }
32
33
 
33
- // The captured original is what the file keeps, so it must hold no router stamp:
34
- // the router can mark a child route-active before this capture runs.
34
+ // The captured original is what the file keeps, so it must hold no router stamp
35
+ // and no ephemeral state: either can be set on a child before this capture runs.
35
36
  function authoredInnerHTML(el) {
36
- if (!el.querySelector(`[${ROUTE_ACTIVE_ATTR}]`)) return el.innerHTML;
37
+ const html = el.innerHTML;
38
+ if (!el.querySelector(`[${ROUTE_ACTIVE_ATTR}]`) && !html.includes("ephemeral") && !html.includes("<dialog")) return html;
37
39
  const copy = el.cloneNode(true);
38
40
  for (const s of copy.querySelectorAll(`[${ROUTE_ACTIVE_ATTR}]`)) s.removeAttribute(ROUTE_ACTIVE_ATTR);
41
+ for (const child of copy.children) stripEphemeral(child);
39
42
  return copy.innerHTML;
40
43
  }
41
44
 
@@ -96,7 +96,17 @@ export default function enablePersistentFormInputValues(filterBySelector = PERSI
96
96
  };
97
97
  finalize(inputSelector);
98
98
  finalize(textareaSelector);
99
- finalize(selectSelector);
99
+ // A select's options pair through provenance, not by index: an excluded option
100
+ // (editor-ui, no-snapshot) is already gone from the copy, so its options are a
101
+ // subset of the live ones.
102
+ for (const control of doc.querySelectorAll(selectSelector)) {
103
+ if (!provenance.original(control)) continue;
104
+ for (const option of control.options) {
105
+ const live = provenance.original(option);
106
+ if (!(live && live.selected)) option.removeAttribute('selected');
107
+ else if (!option.hasAttribute('selected')) option.setAttribute('selected', '');
108
+ }
109
+ }
100
110
  });
101
111
  }
102
112
 
package/src/core/save.js CHANGED
@@ -21,7 +21,8 @@ import {
21
21
  saveFateIsUnknown,
22
22
  withKeepalive
23
23
  } from "./save-core.js";
24
- import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
24
+ import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck } from "./snapshot.js";
25
+ import { NO_TRIGGER_AUTOSAVE_SELECTOR } from "../lib/region-policy.js";
25
26
  import { seedEtag, lastSeenEtag } from "./etag.js";
26
27
  import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
27
28
  import { hasUnsavedState } from "../lib/unsaved-state.js";
@@ -189,6 +190,12 @@ let lastSavedContents = '';
189
190
  let lastSavedDirty = '';
190
191
  // A save was requested while one was on the wire; run one more when it settles.
191
192
  let pendingSave = false;
193
+ // An autosave arrived while one of our saves was on the wire. When that save lands
194
+ // it runs the autosave check again, rules and all, rather than an explicit save.
195
+ let pendingAutosave = false;
196
+ // True while a savePage/savePageForce request is on the wire. Only those drain the
197
+ // queue when they land, so an autosave never queues behind a raw saveHtml call.
198
+ let ownSaveOnWire = false;
192
199
 
193
200
  // ============================================
194
201
  // AUTOSAVE SUSPENSION
@@ -236,6 +243,9 @@ export function autosaveIsSuspended() {
236
243
  // save asked for meanwhile is remembered and runs on release.
237
244
  let previewHold = 0;
238
245
  let previewMissed = false;
246
+ // An autosave skipped by the hold. Replayed as an autosave, so it keeps autosave
247
+ // rules (a batching-region edit still waits for an explicit save).
248
+ let previewAutosaveMissed = false;
239
249
  const PREVIEW_HOLD_REASON = 'Waiting for the AI edit to be kept or reverted';
240
250
  const holdReasons = new Map();
241
251
 
@@ -252,8 +262,12 @@ export function releaseAllSaves({ replay = true, reason = PREVIEW_HOLD_REASON }
252
262
  previewHold--;
253
263
  if (previewHold > 0) return;
254
264
  const missed = previewMissed;
265
+ const autosaveMissedInHold = previewAutosaveMissed;
255
266
  previewMissed = false;
256
- if (missed && replay) savePage();
267
+ previewAutosaveMissed = false;
268
+ if (!replay) return;
269
+ if (missed) savePage();
270
+ else if (autosaveMissedInHold) savePageThrottled();
257
271
  }
258
272
 
259
273
  export function savesHeld() {
@@ -407,10 +421,16 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
407
421
  // dropped, so the newer bytes still go out on the next save; the page is still
408
422
  // dirty, so an edit or a close warning will produce one.
409
423
  function drainPendingSave() {
410
- if (!pendingSave) return;
424
+ if (!pendingSave && !pendingAutosave) return;
411
425
  if (saveFateIsUnknown()) return;
412
- pendingSave = false;
413
- savePage();
426
+ if (pendingSave) {
427
+ pendingSave = false;
428
+ pendingAutosave = false;
429
+ savePage();
430
+ return;
431
+ }
432
+ pendingAutosave = false;
433
+ savePageThrottled();
414
434
  }
415
435
 
416
436
  // State accessors for autosave module
@@ -526,7 +546,9 @@ export function savePage(callback = () => {}) {
526
546
  setSavingState();
527
547
 
528
548
  // Use saveHtml directly with our pre-captured content (avoids double capture)
549
+ ownSaveOnWire = true;
529
550
  saveHtml(forSave, (result) => {
551
+ ownSaveOnWire = false;
530
552
  applySaveResult(result, forComparison, forDirty, 'updated after save', gateToken, forSave);
531
553
  if (typeof callback === 'function') {
532
554
  callback(result);
@@ -581,7 +603,9 @@ export function savePageForce(callback = () => {}) {
581
603
 
582
604
  setSavingState();
583
605
 
606
+ ownSaveOnWire = true;
584
607
  saveHtml(forSave, (result) => {
608
+ ownSaveOnWire = false;
585
609
  applySaveResult(result, forComparison, forDirty, 'updated after force save', gateToken, forSave);
586
610
  if (typeof callback === 'function') {
587
611
  callback(result);
@@ -878,7 +902,7 @@ export function savePageThrottled(callback = () => {}) {
878
902
  }
879
903
 
880
904
  if (previewHold > 0) {
881
- previewMissed = true;
905
+ previewAutosaveMissed = true;
882
906
  const skipped = skipped_(saveHoldReason());
883
907
  callback(skipped);
884
908
  return Promise.resolve(skipped);
@@ -894,6 +918,17 @@ export function savePageThrottled(callback = () => {}) {
894
918
  return Promise.resolve(skipped);
895
919
  }
896
920
 
921
+ // One of our saves is on the wire: queue this autosave instead of copying the
922
+ // page to compare. When that save lands, drainPendingSave runs this check again.
923
+ // Not while the page is settling, where every candidate save must pass the
924
+ // baseline veto below.
925
+ if (!baselineActive && ownSaveOnWire) {
926
+ pendingAutosave = true;
927
+ const skipped = skipped_('Save already in progress');
928
+ callback(skipped);
929
+ return Promise.resolve(skipped);
930
+ }
931
+
897
932
  // For autosave: while the page is still settling, content must differ from BOTH
898
933
  // the load-time baseline and the last save, so module setup churn cannot trigger
899
934
  // a save. Once settled, the baseline veto is disarmed and only the last save
@@ -901,7 +936,21 @@ export function savePageThrottled(callback = () => {}) {
901
936
  // persisted, because it matches the baseline forever.
902
937
  // Compare directly - stored versions are already stripped
903
938
  const gateToken = gateCaptureToken();
904
- const currentForCompare = captureForComparison();
939
+ let currentForCompare;
940
+ let dirtyMatchesLastSave;
941
+ try {
942
+ currentForCompare = captureForComparison();
943
+ // This comparison leaves out batching (no-trigger-autosave) regions, which the
944
+ // gate counts, so on a page that has one the dirty domain must match too.
945
+ dirtyMatchesLastSave = () => !document.querySelector(NO_TRIGGER_AUTOSAVE_SELECTOR)
946
+ || (pageMaybeDirty() && captureForDirtyCheck({ flushUndo: false }) === lastSavedDirty);
947
+ } catch (err) {
948
+ console.error('savePageThrottled: captureForComparison failed', err);
949
+ setSaveState('error', err.message);
950
+ const result = { msg: err.message, msgType: 'error', code: null, etag: null };
951
+ callback(result);
952
+ return Promise.resolve(result);
953
+ }
905
954
  const differsFromBaseline = !baselineActive || currentForCompare !== baselineContents;
906
955
  const differsFromLastSave = currentForCompare !== lastSavedContents;
907
956
 
@@ -909,7 +958,13 @@ export function savePageThrottled(callback = () => {}) {
909
958
  logSaveCheck('throttled vs lastSave', !differsFromLastSave);
910
959
 
911
960
  if (!(differsFromBaseline && differsFromLastSave)) {
912
- if (!differsFromLastSave) gateClearIfUnchanged(gateToken);
961
+ let clear = false;
962
+ try {
963
+ clear = !differsFromLastSave && dirtyMatchesLastSave();
964
+ } catch (err) {
965
+ console.error('savePageThrottled: captureForDirtyCheck failed', err);
966
+ }
967
+ if (clear) gateClearIfUnchanged(gateToken);
913
968
  const skipped = skipped_('No changes to save');
914
969
  callback(skipped);
915
970
  return Promise.resolve(skipped);
@@ -42,6 +42,7 @@ import { stripExtensionNoise } from '../lib/extension-noise.js';
42
42
  import { restoreAuthoredUrls } from '../lib/authored-url.js';
43
43
  import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, STRIP_FROM_DIRTY_CHECK, NO_TRIGGER_AUTOSAVE_SELECTOR, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
44
44
  import { TAB_LOCAL_ROOT_ATTRS, stripRouteAttrs } from '../lib/root-attrs.js';
45
+ import { stripEphemeral } from '../lib/ephemeral.js';
45
46
  import { createContentView } from '../lib/content-dom.js';
46
47
 
47
48
  // =============================================================================
@@ -236,23 +237,11 @@ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
236
237
  window.clay.undo.flush();
237
238
  }
238
239
 
239
- const view = createContentView(document.documentElement, { capability: 'snapshot' });
240
+ // The view records each clone node's live original straight into the provenance
241
+ // map as it clones, including the nodes inside a <template>'s content fragment,
242
+ // so the source map can ask which live node any clone node came from.
243
+ const view = createContentView(document.documentElement, { capability: 'snapshot', provenance: snapshotProvenance });
240
244
  const clone = view.root;
241
- // A <template>'s children are not its childNodes, they are its content fragment's,
242
- // so walking childNodes alone leaves everything inside a template with no provenance.
243
- // Nothing noticed until the source map asked which live node a clone node came from
244
- // and got null for every node in a template, which reprinted the whole template on
245
- // every save. createContentView already maps the fragment itself, so descending into
246
- // it is all that was missing.
247
- const remember = (node) => {
248
- const live = view.original(node);
249
- if (live) snapshotProvenance.set(node, live);
250
- const children = node.nodeType === 1 && node.tagName === 'TEMPLATE' && node.content
251
- ? node.content.childNodes
252
- : node.childNodes;
253
- for (const child of children || []) remember(child);
254
- };
255
- remember(clone);
256
245
 
257
246
  for (const hook of snapshotHooks) {
258
247
  stripSnapshotRegions(clone);
@@ -280,6 +269,9 @@ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
280
269
  // Router state says where this tab is, not what the document holds.
281
270
  stripRouteAttrs(clone);
282
271
 
272
+ // So does ephemeral state: a selection, an open dialog, a marked attribute.
273
+ stripEphemeral(clone);
274
+
283
275
  return clone;
284
276
  }
285
277
 
@@ -587,7 +587,16 @@ export function render(clone, map, m, provenance, opts = {}) {
587
587
  // `from >= 0` is not defensive padding: `src.slice(-1, n)` is the LAST BYTE of the
588
588
  // source, so an unset offset reaching here does not produce nothing, it produces one
589
589
  // wrong byte and looks like a successful render.
590
- const keep = (from, to) => { if (from >= 0 && to > from) pieces.push({ from, to }); };
590
+ // Adjacent copied ranges merge into one piece, except across a mark that records
591
+ // a piece index (tailMark, afterBody): a piece sealed under a mark is never extended,
592
+ // so the next range starts a new piece at the index the mark points to.
593
+ let sealedAt = 0;
594
+ const keep = (from, to) => {
595
+ if (!(from >= 0 && to > from)) return;
596
+ const last = pieces.length > sealedAt ? pieces[pieces.length - 1] : null;
597
+ if (last && last.text === undefined && last.to === from) last.to = to;
598
+ else pieces.push({ from, to });
599
+ };
591
600
  const text = (t) => { if (t) pieces.push({ text: t }); };
592
601
  // Elements in `opts.print`, and everything inside them, are printed rather than
593
602
  // copied: the answer to a region whose copy did not verify.
@@ -622,7 +631,7 @@ export function render(clone, map, m, provenance, opts = {}) {
622
631
  // Called once <body>'s end tag, if any, has been emitted. The owner is still last when
623
632
  // nothing but end tags was emitted after its text.
624
633
  function endOfBody() {
625
- afterBody = pieces.length;
634
+ afterBody = pieces.length; sealedAt = pieces.length;
626
635
  tailClean = !!tailMark && tailMark.whole
627
636
  && /^(?:<\/[^>]*>)*$/.test(pieces.slice(tailMark.at).map(piece).join(''));
628
637
  }
@@ -660,7 +669,7 @@ export function render(clone, map, m, provenance, opts = {}) {
660
669
  // Test switch: copy one named text node's bytes twice, a corruption only the
661
670
  // copy path makes, so printing the element around it is the fix.
662
671
  if (opts.doubleCopied !== undefined && data === opts.doubleCopied) keep(loc.from, loc.to);
663
- if (loc.outsideTail) tailMark = { at: pieces.length, tail: loc.outsideTail, whole: true };
672
+ if (loc.outsideTail) { tailMark = { at: pieces.length, tail: loc.outsideTail, whole: true }; sealedAt = pieces.length; }
664
673
  return;
665
674
  }
666
675
  let owner = null;
@@ -670,7 +679,7 @@ export function render(clone, map, m, provenance, opts = {}) {
670
679
  owner = { tail: whole ? loc.outsideTail : '', whole };
671
680
  }
672
681
  text(RAW_TEXT.has(parentTag) ? data : escText(data));
673
- if (owner) tailMark = { at: pieces.length, ...owner };
682
+ if (owner) { tailMark = { at: pieces.length, ...owner }; sealedAt = pieces.length; }
674
683
  }
675
684
  function emitElement(n, loc) {
676
685
  const tag = n.localName;
@@ -719,7 +728,20 @@ export function render(clone, map, m, provenance, opts = {}) {
719
728
  // clone still has with the same value copies its bytes; a changed one keeps the
720
729
  // author's quoting; a removed one takes its leading whitespace with it; a new one is
721
730
  // appended just before the `>`.
731
+ // The open tag copies as one range when the clone's attributes are the source's, in
732
+ // the source's order, with the same values: the attribute walk below would copy the
733
+ // same bytes piece by piece.
734
+ function sameAttrs(n, loc) {
735
+ const attrs = n.attributes;
736
+ if (attrs.length !== loc.attrs.length) return false;
737
+ for (let i = 0; i < attrs.length; i++) {
738
+ const a = loc.attrs[i];
739
+ if (a.from < 0 || attrs[i].value !== a.value || attrs[i].name.toLowerCase() !== a.key) return false;
740
+ }
741
+ return true;
742
+ }
722
743
  function emitOpenTag(n, loc) {
744
+ if (sameAttrs(n, loc)) { keep(loc.openFrom, loc.openTo); return; }
723
745
  const live = new Map();
724
746
  for (const a of n.attributes) live.set(a.name.toLowerCase(), a);
725
747
  const seen = new Set();
@@ -832,7 +854,7 @@ export function render(clone, map, m, provenance, opts = {}) {
832
854
  if (!whole.has(i)) return emitNode(c, parentTag);
833
855
  const s = S[owned[i]];
834
856
  keep(s.from, s.to);
835
- if (s.outsideTail) tailMark = { at: pieces.length, tail: s.outsideTail, whole: true };
857
+ if (s.outsideTail) { tailMark = { at: pieces.length, tail: s.outsideTail, whole: true }; sealedAt = pieces.length; }
836
858
  };
837
859
  const openOf = (c, i) => {
838
860
  if (!whole.has(i)) return openChain(c);
@@ -903,24 +925,31 @@ function lis(vals) {
903
925
  export function verify(rendered, today, liveDocument, clone = null, sourceErrors = null) {
904
926
  const P = new DOMParser();
905
927
  const A = P.parseFromString(rendered, 'text/html');
906
- const B = P.parseFromString(today, 'text/html');
907
928
  const outsideA = vOutside(A), outsideLive = vOutside(liveDocument);
908
929
  if (outsideA.join('\n') !== outsideLive.join('\n')) return { ok: false, diff: 'document: outside <html> ' + JSON.stringify(outsideA) + ' vs live ' + JSON.stringify(outsideLive) };
909
- const diff = vDiff(A.documentElement, B.documentElement, 'html');
910
- const worse = diff ? null : vParseErrors(rendered, sourceErrors);
911
- if (!diff) return worse ? { ok: false, diff: worse } : { ok: true, diff: null, oracle: 'today' };
930
+ // The clone oracle first: it is the stronger claim and needs no second parse. The
931
+ // accept set is unchanged (equal to the clone, or equal to today's bytes reparsed);
932
+ // only the order the two are tried in differs, and the oracle named is still the
933
+ // one that passed.
934
+ let at = null;
935
+ let cloneDiff = null;
912
936
  if (clone) {
913
- // `at` is the index path, in the clone, of the node where the render first differs,
914
- // so a caller can print that element and try again instead of discarding the whole
915
- // render. It is valid in the clone because every earlier sibling at every level on
916
- // the way down compared equal.
917
- const at = [];
918
- if (!vDiff(A.documentElement, clone, 'html', at)) {
937
+ at = [];
938
+ cloneDiff = vDiff(A.documentElement, clone, 'html', at);
939
+ if (!cloneDiff) {
919
940
  const w = vParseErrors(rendered, sourceErrors);
920
- return w ? { ok: false, diff: w } : { ok: true, diff: null, oracle: 'clone', todayDiff: diff };
941
+ return w ? { ok: false, diff: w } : { ok: true, diff: null, oracle: 'clone' };
921
942
  }
922
- return { ok: false, diff, at };
923
943
  }
944
+ const B = P.parseFromString(today, 'text/html');
945
+ const diff = vDiff(A.documentElement, B.documentElement, 'html');
946
+ const worse = diff ? null : vParseErrors(rendered, sourceErrors);
947
+ if (!diff) return worse ? { ok: false, diff: worse } : { ok: true, diff: null, oracle: 'today' };
948
+ // `at` is the index path, in the clone, of the node where the render first differs,
949
+ // so a caller can print that element and try again instead of discarding the whole
950
+ // render. It is valid in the clone because every earlier sibling at every level on
951
+ // the way down compared equal.
952
+ if (clone) return { ok: false, diff, at };
924
953
  return { ok: false, diff };
925
954
  }
926
955
 
@@ -1004,14 +1033,27 @@ function vNoscript(a, b, path) {
1004
1033
  }
1005
1034
  function vKids(el) {
1006
1035
  const list = el.localName === 'template' && el.content ? el.content.childNodes : el.childNodes;
1007
- return Array.from(list).filter((n) => n.nodeType === 1 || n.nodeType === 3 || n.nodeType === 8);
1036
+ const out = [];
1037
+ for (let i = 0; i < list.length; i++) { const n = list[i]; if (n.nodeType === 1 || n.nodeType === 3 || n.nodeType === 8) out.push(n); }
1038
+ return out;
1008
1039
  }
1009
1040
  const vAttrs = (el) => Array.from(el.attributes, (a) => (a.namespaceURI || '') + ' ' + a.name + '=' + JSON.stringify(a.value)).sort().join('; ');
1041
+ // Equal attribute lists in the same order need no sorted strings; any other case is
1042
+ // decided by the strings, as before.
1043
+ function sameAttrsInOrder(a, b) {
1044
+ const A = a.attributes, B = b.attributes;
1045
+ if (A.length !== B.length) return false;
1046
+ for (let i = 0; i < A.length; i++) {
1047
+ const x = A[i], y = B[i];
1048
+ if (x.name !== y.name || x.value !== y.value || x.namespaceURI !== y.namespaceURI) return false;
1049
+ }
1050
+ return true;
1051
+ }
1010
1052
  function vDiff(a, b, path, at = []) {
1011
1053
  if (a.nodeType !== b.nodeType) return path + ': node type ' + a.nodeType + ' vs ' + b.nodeType;
1012
1054
  if (a.nodeType === 3 || a.nodeType === 8) return a.data === b.data ? null : path + ': text ' + JSON.stringify(a.data.slice(0, 60)) + ' vs ' + JSON.stringify(b.data.slice(0, 60));
1013
1055
  if (a.localName !== b.localName || a.namespaceURI !== b.namespaceURI) return path + ': tag ' + a.localName + ' vs ' + b.localName;
1014
- if (vAttrs(a) !== vAttrs(b)) return path + ': attrs ' + vAttrs(a) + ' vs ' + vAttrs(b);
1056
+ if (!sameAttrsInOrder(a, b) && vAttrs(a) !== vAttrs(b)) return path + ': attrs ' + vAttrs(a) + ' vs ' + vAttrs(b);
1015
1057
  if (a.localName === 'noscript' && a.namespaceURI === XHTML) return vNoscript(a, b, path);
1016
1058
  const ka = vKids(a), kb = vKids(b);
1017
1059
  if (ka.length !== kb.length) return path + ': ' + ka.length + ' vs ' + kb.length + ' children';
@@ -1,4 +1,4 @@
1
- import { capabilitySelector, hasCapability } from './region-capabilities.js'
1
+ import { capabilitySelector, hasCapability, CAPABILITIES, expandsTo, hasPolicyToken } from './region-capabilities.js'
2
2
 
3
3
  const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visited|defined)\b/i
4
4
 
@@ -7,10 +7,9 @@ function copyControlState(source, copy) {
7
7
  const type = (source.getAttribute?.('type') || '').toLowerCase()
8
8
  if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio' && type !== 'file') copy.value = source.value
9
9
  if (source.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) copy.checked = source.checked
10
+ // Each surviving option carries its own selection, so a <select> whose excluded
11
+ // options were removed from the copy keeps the rest in step with the page.
10
12
  if (source.tagName === 'OPTION') copy.selected = source.selected
11
- if (source.tagName === 'SELECT') {
12
- for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
13
- }
14
13
  if ('indeterminate' in source && 'indeterminate' in copy) copy.indeterminate = source.indeterminate
15
14
  }
16
15
 
@@ -25,46 +24,77 @@ function matchesWithin(node, selector, boundary, inherit) {
25
24
  return false
26
25
  }
27
26
 
28
- function importTree(source, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps) {
29
- if (source.nodeType === 1 && (
30
- (inherit
31
- ? hasCapability(source, capability)
32
- : matchesWithin(source, capabilityMatch, boundary, false)) ||
33
- (exclude && (inherit && source !== boundary ? source.matches(exclude) : matchesWithin(source, exclude, boundary, inherit)))
34
- )) return null
35
- const copy = targetDocument.importNode(source, false)
27
+ // One native deep clone, then a lockstep walk of the two isomorphic trees that
28
+ // records provenance, collects controls, and marks excluded subtrees for removal.
29
+ // The browser's clone is far cheaper than importing node by node. The root answers
30
+ // for its ancestors once; below it the walk is top-down, so with inheritance each
31
+ // element answers only for its own markers: an excluded ancestor is never entered.
32
+ function excluded(source, capability, capabilityMatch, exclude, boundary, inherit) {
33
+ return (inherit
34
+ ? hasCapability(source, capability)
35
+ : matchesWithin(source, capabilityMatch, boundary, false)) ||
36
+ !!(exclude && (inherit && source !== boundary ? source.matches(exclude) : matchesWithin(source, exclude, boundary, inherit)))
37
+ }
38
+
39
+ // The test closestWithCapability makes at each level, for this element alone.
40
+ function hasOwnCapability(element, capability) {
41
+ const definition = CAPABILITIES[capability]
42
+ return definition.tokens.some(token => expandsTo(element, token)) ||
43
+ definition.bundles.some(bundle => hasPolicyToken(element, bundle))
44
+ }
45
+
46
+ const CONTROL_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'OPTION'])
47
+
48
+ function pairTree(source, copy, maps, drop) {
49
+ if (source.nodeType === 1 && drop(source)) { maps.dropped.push(copy); return }
36
50
  maps.cloneToLive.set(copy, source)
37
51
  maps.liveToClone.set(source, copy)
38
- const sourceChildren = source.nodeType === 1 && source.tagName === 'TEMPLATE' ? source.content : source
39
- const copyChildren = copy.nodeType === 1 && copy.tagName === 'TEMPLATE' ? copy.content : copy
40
- if (sourceChildren !== source) {
41
- maps.cloneToLive.set(copyChildren, sourceChildren)
42
- maps.liveToClone.set(sourceChildren, copyChildren)
43
- }
44
- for (const child of sourceChildren.childNodes || []) {
45
- const childCopy = importTree(child, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps)
46
- if (childCopy) {
47
- copyChildren.appendChild(childCopy)
48
- if (child.nodeType === 1) copyControlState(child, childCopy)
52
+ let sourceChildren = source
53
+ let copyChildren = copy
54
+ if (source.nodeType === 1) {
55
+ if (CONTROL_TAGS.has(source.tagName)) maps.controls.push(source, copy)
56
+ if (source.tagName === 'TEMPLATE') {
57
+ // The deep clone also copied the template's own child nodes, which are not its
58
+ // content and were never part of the view.
59
+ while (copy.firstChild) copy.removeChild(copy.firstChild)
60
+ sourceChildren = source.content
61
+ copyChildren = copy.content
62
+ maps.cloneToLive.set(copyChildren, sourceChildren)
63
+ maps.liveToClone.set(sourceChildren, copyChildren)
49
64
  }
65
+ } else if (source.nodeType !== 11) return
66
+ let c = copyChildren.firstChild
67
+ for (let s = sourceChildren.firstChild; s && c; s = s.nextSibling) {
68
+ pairTree(s, c, maps, drop)
69
+ c = c.nextSibling
50
70
  }
51
- if (source.nodeType === 1) copyControlState(source, copy)
52
- return copy
53
71
  }
54
72
 
55
- export function createContentView(context, { capability = 'data', exclude = null, inherit = true } = {}) {
73
+ export function createContentView(context, { capability = 'data', exclude = null, inherit = true, provenance = null } = {}) {
56
74
  if (!context) throw new TypeError('createContentView requires a DOM context')
57
75
  const sourceDocument = context.nodeType === 9 ? context : context.ownerDocument
58
76
  if (!sourceDocument?.implementation?.createHTMLDocument) {
59
77
  throw new TypeError('createContentView requires an HTML DOM implementation')
60
78
  }
61
79
  const inertDocument = sourceDocument.implementation.createHTMLDocument('')
62
- const cloneToLive = new WeakMap()
80
+ const cloneToLive = provenance || new WeakMap()
63
81
  const liveToClone = new WeakMap()
64
82
  const sourceRoot = context.nodeType === 9 ? context.documentElement : context
65
83
  if (exclude) inertDocument.documentElement.matches(exclude)
66
84
  const capabilityMatch = capabilitySelector(capability)
67
- const root = importTree(sourceRoot, inertDocument, capability, capabilityMatch, exclude, sourceRoot, inherit, { cloneToLive, liveToClone })
85
+ const drop = inherit
86
+ ? (el) => hasOwnCapability(el, capability) || !!(exclude && el.matches(exclude))
87
+ : (el) => excluded(el, capability, capabilityMatch, exclude, sourceRoot, false)
88
+ let root = null
89
+ if (sourceRoot.nodeType !== 1 || !excluded(sourceRoot, capability, capabilityMatch, exclude, sourceRoot, inherit)) {
90
+ root = inertDocument.importNode(sourceRoot, true)
91
+ const maps = { cloneToLive, liveToClone, dropped: [], controls: [] }
92
+ pairTree(sourceRoot, root, maps, drop)
93
+ for (const node of maps.dropped) node.remove()
94
+ // Control state is copied once the copy holds only what the view keeps, so a
95
+ // <select> sees exactly the options the old node-by-node import gave it.
96
+ for (let i = 0; i < maps.controls.length; i += 2) copyControlState(maps.controls[i], maps.controls[i + 1])
97
+ }
68
98
  if (context.nodeType === 9 && root) {
69
99
  inertDocument.replaceChild(root, inertDocument.documentElement)
70
100
  liveToClone.set(context, inertDocument)
@@ -0,0 +1,88 @@
1
+ import { isPolicyAttribute } from "./region-capabilities.js";
2
+
3
+ // Tab-local state on saved elements. The ephemeral attribute holds words a
4
+ // script sets for this tab (selected, dragging), and an ephemeral:NAME marker
5
+ // makes the element's own NAME attribute tab-local too. A dialog's open is
6
+ // tab-local with no marker: showModal() sets it, and a dialog saved open is
7
+ // always a bug. Like route-active, none of it is saved, synced, undone or
8
+ // counted as a change. The marker itself is ordinary saved content.
9
+
10
+ export const EPHEMERAL_ATTR = "ephemeral";
11
+ const MARKER = "ephemeral:";
12
+ // Identity, region attributes (a marker must not switch a region off) and form
13
+ // state (persist owns those) can never be ephemeral.
14
+ const NEVER = new Set([
15
+ "class", "id", "data-id", "value", "checked", "selected",
16
+ "save-remove", "save-ignore", "save-freeze", "mutations-ignore", "snapshot-remove",
17
+ ]);
18
+ const never = (name) => NEVER.has(name) || isPolicyAttribute(name);
19
+
20
+ export function isEphemeralAttr(name, el) {
21
+ if (name === EPHEMERAL_ATTR) return true;
22
+ if (!name || !el || el.nodeType !== 1 || never(name) || name.startsWith(MARKER)) return false;
23
+ if (name === "open" && el.localName === "dialog") return true;
24
+ return el.hasAttribute(MARKER + name);
25
+ }
26
+
27
+ /** True when `el` carries an ephemeral:NAME marker for `name`. */
28
+ export function isMarkedAttr(name, el) {
29
+ return !!el && el.nodeType === 1 && el.hasAttribute(MARKER + name);
30
+ }
31
+
32
+ function stripElement(el) {
33
+ if (el.localName === "dialog" && el.hasAttribute("open")) el.removeAttribute("open");
34
+ if (el.hasAttribute(EPHEMERAL_ATTR)) el.removeAttribute(EPHEMERAL_ATTR);
35
+ const attrs = el.attributes;
36
+ let names = null;
37
+ for (let i = 0; i < attrs.length; i++) {
38
+ const name = attrs[i].name;
39
+ if (!name.startsWith(MARKER)) continue;
40
+ const target = name.slice(MARKER.length);
41
+ if (target && !never(target)) (names || (names = [])).push(target);
42
+ }
43
+ if (names) for (const name of names) el.removeAttribute(name);
44
+ }
45
+
46
+ /** Remove ephemeral state from a snapshot clone, in place: the root, every descendant, and template contents. */
47
+ export function stripEphemeral(root) {
48
+ if (!root || root.nodeType !== 1) return;
49
+ stripElement(root);
50
+ for (const el of root.querySelectorAll("*")) {
51
+ stripElement(el);
52
+ if (el.localName === "template" && el.content) {
53
+ for (const child of el.content.children) stripEphemeral(child);
54
+ }
55
+ }
56
+ if (root.localName === "template" && root.content) {
57
+ for (const child of root.content.children) stripEphemeral(child);
58
+ }
59
+ }
60
+
61
+ function words(el) {
62
+ return (el.getAttribute(EPHEMERAL_ATTR) || "").split(/\s+/).filter(Boolean);
63
+ }
64
+
65
+ function write(el, list) {
66
+ if (list.length) el.setAttribute(EPHEMERAL_ATTR, list.join(" "));
67
+ else el.removeAttribute(EPHEMERAL_ATTR);
68
+ }
69
+
70
+ export const ephemeral = {
71
+ has(el, word) {
72
+ return words(el).includes(word);
73
+ },
74
+ add(el, word) {
75
+ const list = words(el);
76
+ if (!list.includes(word)) write(el, [...list, word]);
77
+ },
78
+ remove(el, word) {
79
+ const list = words(el);
80
+ if (list.includes(word)) write(el, list.filter((w) => w !== word));
81
+ },
82
+ toggle(el, word, force) {
83
+ const on = force === undefined ? !ephemeral.has(el, word) : !!force;
84
+ if (on) ephemeral.add(el, word);
85
+ else ephemeral.remove(el, word);
86
+ return on;
87
+ },
88
+ };
@@ -55,12 +55,19 @@ export function stripExtensionNoise(root) {
55
55
  // querySelectorAll('*') never returns the root itself, so an extension stamp on
56
56
  // <html> used to ride into every saved file. The root is the one element an
57
57
  // extension can mark on a page with no other match.
58
- for (const el of [root, ...root.querySelectorAll('*')]) {
59
- if (!el.attributes) continue
60
- for (const attr of [...el.attributes]) {
61
- if (EXTENSION_ATTR_PATTERN.test(attr.name.toLowerCase())) el.removeAttribute(attr.name)
58
+ // The attribute list is only copied when a marker is found on it.
59
+ const strip = (el) => {
60
+ const attrs = el.attributes
61
+ if (!attrs) return
62
+ let found = null
63
+ for (let i = 0; i < attrs.length; i++) {
64
+ const name = attrs[i].name
65
+ if (EXTENSION_ATTR_PATTERN.test(name.toLowerCase())) (found || (found = [])).push(name)
62
66
  }
67
+ if (found) for (const name of found) el.removeAttribute(name)
63
68
  }
69
+ strip(root)
70
+ for (const el of root.querySelectorAll('*')) strip(el)
64
71
  } catch (e) {
65
72
  if (typeof console !== 'undefined') console.warn('[clay] stripExtensionNoise skipped:', e)
66
73
  }
@@ -70,6 +70,7 @@ import { EXTENSION_ATTR_PATTERN } from './extension-noise.js';
70
70
  import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './region-policy.js';
71
71
  import { isPolicyAttribute } from './region-capabilities.js';
72
72
  import { ROOT_LIBRARY_ATTRS, ROUTE_ROOT_PREFIX, ROUTE_ACTIVE_ATTR } from './root-attrs.js';
73
+ import { isEphemeralAttr } from './ephemeral.js';
73
74
 
74
75
  import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
75
76
 
@@ -83,13 +84,15 @@ const dummyElem = document.createElement("div");
83
84
  // instead of body. The raw lane is deliberately unfiltered ("raw means raw"), so
84
85
  // without this every save would push a savestatus flip into undo's record stream
85
86
  // and Cmd+Z after a save would undo that instead of the user's edit.
86
- // Router state (route-* on the root, route-active anywhere) is dropped here for the same reason.
87
+ // Router state (route-* on the root, route-active anywhere) and ephemeral state
88
+ // (lib/ephemeral.js) are dropped here for the same reason.
87
89
  const ROOT_LIBRARY_ATTR_SET = new Set(ROOT_LIBRARY_ATTRS);
88
90
 
89
91
  function isLibraryRootAttr(record) {
90
92
  if (record.type !== 'attributes') return false;
91
93
  const name = record.attributeName;
92
94
  if (name === ROUTE_ACTIVE_ATTR) return true;
95
+ if (isEphemeralAttr(name, record.target)) return true;
93
96
  return record.target === document.documentElement
94
97
  && (ROOT_LIBRARY_ATTR_SET.has(name) || name.startsWith(ROUTE_ROOT_PREFIX));
95
98
  }