cyborg-hunter 0.7.5 → 0.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +21 -6
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +3 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/package.json +10 -2
  9. package/src/cli/ingest.js +311 -57
  10. package/src/cli/renderers/html-index-core.js +66 -12
  11. package/src/cli/renderers/html-index.js +7 -5
  12. package/src/cli/renderers/replay-assets.js +61 -7
  13. package/src/cli/renderers/replay-client-source.js +102 -0
  14. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  15. package/src/cli/report.js +6 -0
  16. package/src/core/monitor.js +12 -13
  17. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  18. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  19. package/src/replay/capture-dom.js +470 -458
  20. package/src/replay/capture-trace.js +688 -271
  21. package/src/replay/delivery.js +82 -0
  22. package/src/replay/dom-instantiate.js +779 -0
  23. package/src/replay/index.js +88 -5
  24. package/src/replay/initial-state.js +295 -0
  25. package/src/replay/mutations.js +668 -0
  26. package/src/replay/node-registry.js +116 -0
  27. package/src/replay/persistence.js +19 -6
  28. package/src/replay/recorder.js +342 -73
  29. package/src/replay/redaction.js +165 -0
  30. package/src/replay/serializer.js +148 -43
  31. package/src/replay/snapshot.js +409 -0
  32. package/src/replay/span.js +55 -0
  33. package/src/replay/viewer-model.js +293 -102
  34. package/src/shared/constants.js +1 -1
  35. package/src/shared/inline-safe.js +80 -0
  36. package/src/shared/schema-v2-validator.js +595 -0
  37. package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
@@ -0,0 +1,668 @@
1
+ // src/replay/mutations.js
2
+ // MutationObserver batches → v2 `dom.*` patch events (spec §5.1).
3
+ //
4
+ // The mid-span counterpart to snapshot.js: a keyframe is a DomNode tree, and
5
+ // everything that happens to the DOM afterwards is expressed as patches
6
+ // addressing that tree's integer ids. Pure and observer-free — it maps an
7
+ // ARRAY of MutationRecords to an array of events — so it can be tested against
8
+ // records a real observer produced without a recorder, a clock, or a browser
9
+ // in the loop. The live wiring is capture-dom.js's observer callback, which
10
+ // hands over the batch exactly as the observer reported it.
11
+ //
12
+ // Everything is decided at FLUSH time, against the DOM as it stands when the
13
+ // observer callback runs, which is also the state serializeTree sees:
14
+ // - an inserted node still attached is added; one that the same batch removed
15
+ // again is not mentioned at all (it never entered the file);
16
+ // - `before` is resolved from the live sibling chain, skipping siblings the
17
+ // player does not currently hold. (A MutationRecord's own `nextSibling` is
18
+ // the sibling at MUTATION time, which mixes two moments of the DOM's
19
+ // history in one patch — and happy-dom does not populate it at all.)
20
+ //
21
+ // Flush time is not enough on its own, because a patch is a statement about
22
+ // what the PLAYER holds, and the live DOM is the destination rather than the
23
+ // question. Two rounds of review fixed that one composition at a time — a
24
+ // removal into a fragment, a sibling purged with its ancestor, a reorder
25
+ // inside a moved subtree — and a differential fuzz kept finding more. So the
26
+ // module tracks the answer instead of inferring it:
27
+ //
28
+ // - `delivery` (delivery.js) is what the file currently contains: which
29
+ // nodes, under which parents. Removals, `before` anchors, insertions and
30
+ // exclusion sequences all consult it, and it is maintained by the same
31
+ // serializer that writes the file.
32
+ // - `payloads` records the subtrees this batch has already serialized. A
33
+ // payload is written from the FINAL DOM, so any record describing a
34
+ // rearrangement inside one is history the payload already tells.
35
+ // - a PRE-SCAN counts the removals and insertions still to be processed, so
36
+ // a `before` never names a sibling whose position is not settled, and
37
+ // keeps the first `oldValue` per (element, attribute), so an exclusion
38
+ // toggle is decided against the pre-batch element rather than a
39
+ // half-applied one.
40
+ //
41
+ // Exclusion transitions are emitted BEFORE the record loop, which leaves every
42
+ // element's exclusion state in the file equal to its flush-time state for the
43
+ // rest of the batch — the property the rest of the mapping assumes.
44
+ //
45
+ // tests/replay/mutations-fuzz.test.js is the check that this holds under
46
+ // compositions nobody wrote down: random batches, applied to a player, against
47
+ // what a fresh keyframe of the same DOM would say.
48
+ //
49
+ // RETIRED from v1's observer callback, deliberately:
50
+ // - the one-childList-patch-per-target intra-batch dedup. It existed because
51
+ // every v1 childList patch re-serialized the target's FULL resulting
52
+ // children, so N appends to one container cost O(N²) of the participant's
53
+ // CPU. A `dom.add` carries only the inserted subtree, and `before`
54
+ // resolution short-circuits the append run (see `noHeldFrom`), so the same
55
+ // batch costs O(total inserted) — the storm guard now costs precision
56
+ // (dropping the 2nd..Nth insertion's position) and buys nothing. Measured
57
+ // at one registry lookup per appended node; the test pins a linear BOUND
58
+ // rather than that figure. A forward sibling walk per insertion is not
59
+ // free, and an earlier draft of this module reintroduced v1's O(N²) here.
60
+ // - the characterData→parent-childList fold. It existed because text nodes
61
+ // had no parser-stable address, so text changes had to be re-expressed as
62
+ // the parent's children. Node ids ARE that address: `dom.text` names the
63
+ // text node itself and carries nothing else.
64
+ //
65
+ // Known byte noise, correct but redundant: appending a node and then setting
66
+ // its attributes in one batch emits `dom.attr` patches the freshly serialized
67
+ // subtree already carries, and create-append-move emits add/remove/add where
68
+ // one add would do. Both are idempotent for the player.
69
+
70
+ import {
71
+ serializeTree, isExcluded, nearestEmittedAncestor, isEmittableNode,
72
+ carriesChildren, emittedAttrs, attrPatchValue, ATTR_WITHHELD,
73
+ } from './snapshot.js';
74
+ import { isInRedactedSubtree, markRedacted, isInTaintedSubtree } from './redaction.js';
75
+
76
+ var ELEMENT_NODE = 1;
77
+
78
+ /**
79
+ * The observer configuration this mapper's semantics assume. Exported so the
80
+ * capture wiring cannot drift from what the tests pin.
81
+ *
82
+ * `attributeOldValue` is load-bearing, not diagnostic: an exclusion TOGGLE is
83
+ * detected by re-running the exclusion predicate against the element as it was
84
+ * before the mutation, which needs the old value. Without it, removing
85
+ * `data-record-exclude` would read as an ordinary attribute change and the
86
+ * hidden subtree would never come back.
87
+ */
88
+ export var MUTATION_OBSERVER_INIT = {
89
+ childList: true,
90
+ attributes: true,
91
+ attributeOldValue: true,
92
+ characterData: true,
93
+ subtree: true,
94
+ };
95
+
96
+ /**
97
+ * Map one observer callback's records to `dom.*` events.
98
+ *
99
+ * @param {Array} records the batch, in the order the observer reported it
100
+ * @param {object} ctx {root, span, t, keepBait?, redactSelector?, taint?}
101
+ * `ctx` doubles as the serializer's options bag — same
102
+ * keys, so exclusion and redaction cannot be answered
103
+ * one way here and another way in the keyframe.
104
+ * @returns {Array} events, all stamped with the same `t`
105
+ *
106
+ * One callback = one `t` (spec §7 forbids nothing finer, and the records of a
107
+ * batch are a single task's worth of DOM change). Times are stamped raw; wire
108
+ * rounding belongs to the serializer.
109
+ *
110
+ * Order within the batch is the observer's, with one exception: exclusion
111
+ * transitions lead. They describe the element as the player currently holds
112
+ * it, so running them first is what lets every later patch be read against the
113
+ * flush-time DOM.
114
+ */
115
+ export function mapMutations(records, ctx) {
116
+ var out = [];
117
+ if (!records || !records.length || !ctx) return out;
118
+ // A root that resolved to null (a host that wiped its display container)
119
+ // drops the batch rather than throwing inside the observer callback.
120
+ if (!ctx.root || !ctx.span) return out;
121
+
122
+ var state = {
123
+ out: out,
124
+ root: ctx.root,
125
+ // The capture span (span.js): node ids, and what the file already
126
+ // contains. Every "does the player have this, and where" question is
127
+ // answered from the delivery model rather than inferred from the live DOM.
128
+ span: ctx.span,
129
+ registry: ctx.span.registry,
130
+ delivery: ctx.span.delivery,
131
+ t: ctx.t,
132
+ opts: ctx,
133
+ // Pre-scan results: removals and insertions not yet processed (a `before`
134
+ // may only name a sibling whose position is already final), and
135
+ // element → attribute → its first oldValue.
136
+ pending: new Map(),
137
+ pendingAdd: new Map(),
138
+ attrs: new Map(),
139
+ attrOrder: [],
140
+ // Elements whose exclusion state changed this batch: their own attribute
141
+ // records are already accounted for by the transition sequence. `restored`
142
+ // is the revealed subset, whose re-emitted subtree covers any nested
143
+ // transition inside it.
144
+ transitioned: new Set(),
145
+ restored: new Set(),
146
+ // Subtrees this batch has already serialized. A payload is written from
147
+ // the FINAL state, so every record describing a rearrangement inside one
148
+ // is a description of history the payload already tells.
149
+ payloads: new Set(),
150
+ // parent → the first child of a run that reaches the end of the child list
151
+ // holding nothing the player has. Makes an append run O(1) per insertion.
152
+ noHeldFrom: new Map(),
153
+ };
154
+
155
+ prescan(records, state);
156
+ applyExclusionTransitions(state);
157
+
158
+ for (var i = 0; i < records.length; i++) {
159
+ var record = records[i];
160
+ if (!record) continue;
161
+ if (record.type === 'childList') mapChildList(record, state);
162
+ else if (record.type === 'attributes') mapAttributes(record, state);
163
+ else if (record.type === 'characterData') mapCharacterData(record, state);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ // One pass over the batch, before anything is emitted. Cheap (O(records) plus
169
+ // the removed-node lists) and it is what makes the mapping batch-coherent.
170
+ function prescan(records, state) {
171
+ for (var i = 0; i < records.length; i++) {
172
+ var record = records[i];
173
+ if (!record) continue;
174
+ if (record.type === 'childList') {
175
+ var removed = record.removedNodes || [];
176
+ for (var j = 0; j < removed.length; j++) {
177
+ state.pending.set(removed[j], (state.pending.get(removed[j]) || 0) + 1);
178
+ }
179
+ var added = record.addedNodes || [];
180
+ for (var k = 0; k < added.length; k++) {
181
+ state.pendingAdd.set(added[k], (state.pendingAdd.get(added[k]) || 0) + 1);
182
+ }
183
+ } else if (record.type === 'attributes' && record.target && record.attributeName) {
184
+ var perEl = state.attrs.get(record.target);
185
+ if (!perEl) {
186
+ perEl = new Map();
187
+ state.attrs.set(record.target, perEl);
188
+ state.attrOrder.push(record.target);
189
+ }
190
+ // Keyed by the name the attribute is SPELLED with on the element, so
191
+ // the pre-batch view lines up with `node.attributes` for namespaced
192
+ // attributes too (see qualifiedName).
193
+ var key = qualifiedName(record.target, record) || record.attributeName;
194
+ var seen = perEl.get(key);
195
+ if (seen) seen.count++;
196
+ else perEl.set(key, { first: record.oldValue, count: 1 });
197
+ }
198
+ }
199
+ }
200
+
201
+ // The value this attribute held before the batch: a string, or null for
202
+ // absent. Unmutated attributes read straight from the DOM.
203
+ //
204
+ // A single record ending in ABSENCE is read as "it was there before", whatever
205
+ // `oldValue` says: removeAttribute queues no record for an attribute that is
206
+ // not there, and engines disagree about the old value of an empty-valued
207
+ // attribute (Chromium says "", happy-dom says null). With TWO OR MORE records
208
+ // that reasoning collapses — add-then-remove in one task ends absent having
209
+ // started absent — so the first record's `oldValue` is used, which is the
210
+ // exact pre-batch value.
211
+ function preBatchValue(el, name, state) {
212
+ var perEl = state.attrs.get(el);
213
+ var seen = perEl && perEl.get(name);
214
+ var live = attrValue(el, name);
215
+ if (!seen) return live;
216
+ if (seen.count === 1 && live === null) return seen.first == null ? '' : seen.first;
217
+ return seen.first == null ? null : seen.first;
218
+ }
219
+
220
+ function attrValue(el, name) {
221
+ var attrs = el.attributes || [];
222
+ for (var i = 0; i < attrs.length; i++) {
223
+ if (attrs[i].name === name) return attrs[i].value;
224
+ }
225
+ return null;
226
+ }
227
+
228
+ // Was this element excluded before the batch? Answered by running the ONE
229
+ // exclusion predicate over a reconstruction of the pre-batch element; a second
230
+ // implementation of "is this excluded" is the drift the shared predicates
231
+ // exist to prevent. `isExcluded` reads only nodeType, attribute names, and
232
+ // `id`, so the reconstruction is cheap.
233
+ function wasExcluded(el, state) {
234
+ var id = preBatchValue(el, 'id', state);
235
+ return isExcluded({
236
+ nodeType: ELEMENT_NODE,
237
+ id: id == null ? '' : id,
238
+ attributes: preBatchAttrList(el, state),
239
+ }, state.opts);
240
+ }
241
+
242
+ // The element's attributes as the batch found them: every live attribute at
243
+ // its pre-batch value, plus the ones the batch has since removed. Two callers
244
+ // need this rather than the live list — the exclusion verdict, which must be
245
+ // the one the file was written under, and a collapse, which has to clear the
246
+ // attributes the PLAYER holds, including any this batch removed on the way.
247
+ function preBatchAttrList(el, state) {
248
+ var attrs = [];
249
+ var seen = {};
250
+ var live = el.attributes || [];
251
+ for (var i = 0; i < live.length; i++) {
252
+ var name = live[i].name;
253
+ seen[name] = true;
254
+ var value = preBatchValue(el, name, state);
255
+ if (value !== null) attrs.push({ name: name, value: value });
256
+ }
257
+ var perEl = state.attrs.get(el);
258
+ if (perEl) {
259
+ perEl.forEach(function (unused, name) {
260
+ if (seen[name]) return; // removed during the batch
261
+ var value = preBatchValue(el, name, state);
262
+ if (value !== null) attrs.push({ name: name, value: value });
263
+ });
264
+ }
265
+ return attrs;
266
+ }
267
+
268
+ // Exclusion transitions run BEFORE the record loop, once per element. Two
269
+ // things follow. A placeholder being revealed is provably empty when its
270
+ // children are appended, so nothing has to guess at positions inside it; and
271
+ // an element transitions at most once, so a batch that touches the exclusion
272
+ // attribute twice cannot emit the sequence twice.
273
+ function applyExclusionTransitions(state) {
274
+ var pending = new Map();
275
+ for (var i = 0; i < state.attrOrder.length; i++) {
276
+ var el = state.attrOrder[i];
277
+ if (!el || el.nodeType !== ELEMENT_NODE) continue;
278
+ if (!state.delivery.holds(el)) continue;
279
+ var id = state.registry.peekId(el);
280
+ if (id == null) continue;
281
+ if (wasExcluded(el, state) !== isExcluded(el, state.opts)) pending.set(el, id);
282
+ }
283
+ var done = new Set();
284
+ pending.forEach(function (unused, el) { runTransition(el, pending, done, state); });
285
+ }
286
+
287
+ // ANCESTORS FIRST, whatever order the records arrived in. A reveal re-emits
288
+ // its whole subtree, which already carries any nested transition's outcome:
289
+ // the nested element comes back with its current attributes, or as its own
290
+ // placeholder if it is now excluded. Running the nested transition afterwards
291
+ // would re-add a subtree the player just received, or address children it was
292
+ // never sent — so `insideEmittedAdd` retires it instead. Its own attribute
293
+ // records are still suppressed, because the ancestor's patch carried the
294
+ // element's final state.
295
+ function runTransition(el, pending, done, state) {
296
+ if (done.has(el)) return;
297
+ done.add(el);
298
+ var chain = [];
299
+ for (var p = el.parentNode; p; p = p.parentNode) {
300
+ if (pending.has(p)) chain.push(p);
301
+ if (p === state.root) break;
302
+ }
303
+ for (var i = chain.length - 1; i >= 0; i--) runTransition(chain[i], pending, done, state);
304
+
305
+ state.transitioned.add(el);
306
+ // An earlier transition already re-emitted this element as part of an
307
+ // ancestor's reveal (so the file carries its final state, placeholder
308
+ // included), or took it away with an ancestor's collapse. Either way a
309
+ // sequence addressing it now would contradict the patch that just went out.
310
+ if (!state.delivery.holds(el)) return;
311
+ if (insideRestored(el, state)) return;
312
+ if (isExcluded(el, state.opts)) collapseToPlaceholder(el, pending.get(el), state);
313
+ else restoreFromPlaceholder(el, pending.get(el), state);
314
+ }
315
+
316
+ // Removals before insertions: that is the order the DOM itself performs them
317
+ // in (a replaceChild, or a move, is a removal followed by an insertion), and
318
+ // it is what keeps a moved node's `dom.remove` ahead of its `dom.add`.
319
+ function mapChildList(record, state) {
320
+ var removed = record.removedNodes || [];
321
+ for (var i = 0; i < removed.length; i++) {
322
+ mapRemoved(removed[i], record.target, state);
323
+ }
324
+ var added = record.addedNodes || [];
325
+ for (var j = 0; j < added.length; j++) mapAdded(added[j], record.target, state);
326
+ }
327
+
328
+ function mapRemoved(node, oldParent, state) {
329
+ // This removal is no longer pending, whatever we decide to emit for it.
330
+ decrement(state.pending, node);
331
+
332
+ // The whole question: does the file carry this node under this parent right
333
+ // now? Everything a removal could be wrong about answers here — a script or
334
+ // a hidden subtree the file never carried, a node whose insertion at this
335
+ // position was never emitted, one an ancestor's removal already purged, one
336
+ // an ancestor's re-emission has just re-delivered somewhere else, one that
337
+ // left through a detached subtree the observer stopped reporting.
338
+ // Inside a subtree this batch already serialized: that payload was written
339
+ // from the final DOM, so it has already placed this node where it ends up.
340
+ if (insidePayload(node, state)) return;
341
+
342
+ var delivery = state.delivery;
343
+ if (!delivery.holds(node) || delivery.parentOf(node) !== oldParent) return;
344
+ // The old parent has itself left the observed tree by flush time, so its own
345
+ // removal carries this subtree away. Emitting here would be correct and
346
+ // redundant; dropping keeps the batch to the patches that say something.
347
+ if (nearestEmittedAncestor(oldParent, state.root, state.opts) !== oldParent) return;
348
+
349
+ var id = state.registry.peekId(node);
350
+ if (id == null) return; // never numbered: never in the file
351
+
352
+ state.out.push({ type: 'dom.remove', t: state.t, node: id });
353
+ delivery.purge(node);
354
+ }
355
+
356
+ // Is this node inside a subtree this batch already serialized? The walk starts
357
+ // at the PARENT: a payload root is itself addressable (it can be removed
358
+ // again), only its contents are settled by the payload.
359
+ function insidePayload(node, state) {
360
+ for (var p = node && node.parentNode; p; p = p.parentNode) {
361
+ if (state.payloads.has(p)) return true;
362
+ if (p === state.root) break;
363
+ }
364
+ return false;
365
+ }
366
+
367
+ function decrement(counter, node) {
368
+ var left = (counter.get(node) || 0) - 1;
369
+ if (left > 0) counter.set(node, left);
370
+ else counter.delete(node);
371
+ }
372
+
373
+ function mapAdded(node, into, state) {
374
+ // This insertion is no longer pending, whatever we decide to emit for it.
375
+ decrement(state.pendingAdd, node);
376
+
377
+ var delivery = state.delivery;
378
+ var parent = node.parentNode;
379
+ // This record describes an insertion the batch has already superseded: the
380
+ // node moved on afterwards. The patch belongs at the record that put it
381
+ // where it ends up, or the file states the insertion too early and a sibling
382
+ // added in between lands on the wrong side of it.
383
+ if (into !== parent) return;
384
+ // Serialization is final-state, so a container's payload already carries the
385
+ // children a later record in the same batch put inside it.
386
+ if (insidePayload(node, state)) return;
387
+ // Or the file already carries it exactly here, delivered by an earlier
388
+ // patch in this batch or by the keyframe.
389
+ if (delivery.holds(node) && delivery.parentOf(node) === parent) return;
390
+ // Detached again before the callback ran, outside the observed root, or a
391
+ // node the file never carries (script, anything under a placeholder or an
392
+ // iframe): no patch, and — since serializeTree is never reached — no id.
393
+ if (nearestEmittedAncestor(node, state.root, state.opts) !== node) return;
394
+ if (!delivery.holds(parent)) return; // no parent in the file to add to
395
+
396
+ var before = beforeId(node, state);
397
+ // Before serializing, since serialization is what re-delivers the subtree.
398
+ pullForwardMoves(node, state);
399
+ // …and the parent is re-checked afterwards: pulling a move forward purges a
400
+ // whole subtree, and this insertion's parent can be inside it (a container
401
+ // moved under a node that used to contain it). The later record that settles
402
+ // that subtree carries this node with it.
403
+ if (!delivery.holds(parent)) return;
404
+
405
+ var parentId = state.registry.peekId(parent);
406
+ if (parentId == null) return;
407
+ var tree = serializeTree(node, state.span, state.opts);
408
+ if (!tree) return;
409
+
410
+ state.out.push({
411
+ type: 'dom.add', t: state.t, parent: parentId, before: before, node: tree,
412
+ });
413
+ state.payloads.add(node);
414
+ noteHeld(node, state);
415
+ }
416
+
417
+ // A patch carrying a subtree re-instantiates every node in it, including nodes
418
+ // the file already carries SOMEWHERE ELSE — a node moved into this subtree
419
+ // from elsewhere in the page. The player would then hold it twice, because
420
+ // nothing took the old copy away: the node's own removal record can arrive
421
+ // after this insertion, since the page may insert the container before moving
422
+ // the child into it. Pulling those removals forward keeps "one node, one
423
+ // place" true at every point in the stream.
424
+ //
425
+ // Purging cascades, so a node whose old copy sat inside another node being
426
+ // pulled forward needs no removal of its own; the walk continues past it
427
+ // because a node moved in from a THIRD place still does.
428
+ function pullForwardMoves(node, state) {
429
+ var delivery = state.delivery;
430
+ if (delivery.holds(node) && delivery.parentOf(node) !== node.parentNode) {
431
+ var id = state.registry.peekId(node);
432
+ if (id != null) state.out.push({ type: 'dom.remove', t: state.t, node: id });
433
+ delivery.purge(node);
434
+ }
435
+ var kids = node.childNodes || [];
436
+ for (var i = 0; i < kids.length; i++) pullForwardMoves(kids[i], state);
437
+ }
438
+
439
+ // The id the player should insert before, or null to append. Siblings the
440
+ // player does not currently hold are skipped, and each skip is a case where
441
+ // naming the sibling would put a reference in the file that the player cannot
442
+ // resolve — strict validation cannot catch that (it type-checks `node` fields,
443
+ // it does not resolve them), and the fork's player turns it into a silently
444
+ // dropped patch:
445
+ // - a pending removal or a pending insertion: a sibling whose own patch has
446
+ // not run yet, so its player-side position is not final. The two counters
447
+ // together are what makes "append a row, move a row" in one task come out
448
+ // in the right order;
449
+ // - one the file does not carry under this parent (delivery.js): never
450
+ // emitted, already purged, or delivered somewhere else entirely.
451
+ //
452
+ // `noHeldFrom` is what keeps a mass append linear. Its invariant: from that
453
+ // node to the end of the parent's children, the player holds nothing.
454
+ function beforeId(node, state) {
455
+ var start = node.nextSibling;
456
+ if (!start) return null;
457
+ var run = state.noHeldFrom.get(node.parentNode);
458
+ if (run !== undefined && (run === node || run === start)) return null;
459
+
460
+ for (var sib = start; sib; sib = sib.nextSibling) {
461
+ if (state.pending.get(sib) > 0) continue;
462
+ if (state.pendingAdd.get(sib) > 0) continue;
463
+ if (!state.delivery.holds(sib)) continue;
464
+ if (state.delivery.parentOf(sib) !== node.parentNode) continue;
465
+ var id = state.registry.peekId(sib);
466
+ if (id == null) continue;
467
+ return id;
468
+ }
469
+ state.noHeldFrom.set(node.parentNode, start);
470
+ return null;
471
+ }
472
+
473
+ // An emitted insertion makes `node` held, which can falsify the run recorded
474
+ // for its parent. Two cases keep it: the node IS the run's head (the run now
475
+ // starts one later), or the node sits immediately before the head (the run is
476
+ // untouched, which is every step of an append loop). Anything else — an
477
+ // insertion into the middle, or out of document order — drops the cache rather
478
+ // than reason about positions.
479
+ function noteHeld(node, state) {
480
+ var parent = node.parentNode;
481
+ var run = state.noHeldFrom.get(parent);
482
+ if (run === undefined) return;
483
+ if (run === node) {
484
+ if (node.nextSibling) state.noHeldFrom.set(parent, node.nextSibling);
485
+ else state.noHeldFrom.delete(parent);
486
+ return;
487
+ }
488
+ if (node.nextSibling === run) return;
489
+ state.noHeldFrom.delete(parent);
490
+ }
491
+
492
+ function mapAttributes(record, state) {
493
+ var el = record.target;
494
+ var name = record.attributeName;
495
+ if (!el || el.nodeType !== ELEMENT_NODE || !name) return;
496
+ // The transition sequence already carried this element's complete final
497
+ // attribute state.
498
+ if (state.transitioned.has(el)) return;
499
+ if (!state.delivery.holds(el)) return;
500
+ // Detached from the observed root by flush time: whatever removed it carries
501
+ // the subtree away, so its attribute change has nothing to say.
502
+ if (nearestEmittedAncestor(el, state.root, state.opts) !== el) return;
503
+ var id = state.registry.peekId(el);
504
+ if (id == null) return;
505
+ // A placeholder carries no attributes at all (spec §4). Reaching here means
506
+ // it was a placeholder before the batch too, since transitions are handled
507
+ // above.
508
+ if (isExcluded(el, state.opts)) return;
509
+
510
+ // MutationRecord.attributeName is the LOCAL name, while the attribute is
511
+ // spelled with its prefix in `node.attributes` — so a namespaced attribute
512
+ // has to be re-qualified from the live element. A namespaced REMOVAL cannot
513
+ // be: the prefix left with the attribute, and emitting the bare local name
514
+ // would tell the player to remove a different attribute.
515
+ var spelled = qualifiedName(el, record);
516
+ if (spelled === null) return;
517
+
518
+ var redacted = isRedactedNow(el, state);
519
+ var value = attrPatchValue(el, spelled, redacted);
520
+ if (value === ATTR_WITHHELD) {
521
+ if (redacted) markRedacted(el, state.opts.taint);
522
+ return;
523
+ }
524
+ // Net effect: an attribute set and put back inside one batch changed
525
+ // nothing, and neither did a no-op set.
526
+ if (preBatchValue(el, spelled, state) === attrValue(el, spelled)) return;
527
+
528
+ state.out.push({
529
+ type: 'dom.attr', t: state.t, node: id, name: spelled, value: value,
530
+ });
531
+ }
532
+
533
+ function qualifiedName(el, record) {
534
+ var ns = record.attributeNamespace;
535
+ if (!ns) return record.attributeName;
536
+ var attrs = el.attributes || [];
537
+ for (var i = 0; i < attrs.length; i++) {
538
+ if (attrs[i].localName === record.attributeName && attrs[i].namespaceURI === ns) {
539
+ return attrs[i].name;
540
+ }
541
+ }
542
+ return null;
543
+ }
544
+
545
+ // Redacted by position, or by history: a node whose content this recording has
546
+ // already withheld — or which sits inside one — keeps withholding it after a
547
+ // move (redaction.js). Both halves are SUBTREE questions, and the taint half
548
+ // was node-only until the T3 final review's F-1: new content created inside a
549
+ // moved-out container shipped in full here while the next keyframe withheld the
550
+ // same nodes.
551
+ function isRedactedNow(node, state) {
552
+ return isInRedactedSubtree(node, state.opts.redactSelector) ||
553
+ isInTaintedSubtree(node, state.opts.taint);
554
+ }
555
+
556
+ // Exclusion attribute ADDED to a live element (spec §4): its children leave
557
+ // the file and its attributes are cleared, leaving the bare placeholder a
558
+ // fresh snapshot would have written. The exclusion attribute clears itself
559
+ // too — a placeholder is `{id, kind, tag}` and nothing else.
560
+ // Did an ancestor's reveal already re-emit this element? That patch carried
561
+ // the element's CURRENT state — its own placeholder included, if it is now
562
+ // excluded — so a transition of its own would repeat or contradict it.
563
+ function insideRestored(el, state) {
564
+ for (var p = el.parentNode; p; p = p.parentNode) {
565
+ if (state.restored.has(p)) return true;
566
+ if (p === state.root) break;
567
+ }
568
+ return false;
569
+ }
570
+
571
+ function collapseToPlaceholder(el, id, state) {
572
+ // `carriesChildren(el, false)`: whether the file held this element's
573
+ // children a moment ago, when it was not yet a placeholder. The live DOM
574
+ // already says otherwise, which is why the flag is passed rather than looked
575
+ // up.
576
+ if (carriesChildren(el, false)) {
577
+ // Exactly the children the FILE carries, which is neither the flush-time
578
+ // child list (the same batch may have taken some away, or moved others in
579
+ // that the player was never told about) nor a guess reconstructed from the
580
+ // batch: delivery.js knows.
581
+ var held = state.delivery.childrenOf(el);
582
+ for (var i = 0; i < held.length; i++) {
583
+ var id2 = state.registry.peekId(held[i]);
584
+ if (id2 == null) continue;
585
+ state.out.push({ type: 'dom.remove', t: state.t, node: id2 });
586
+ state.delivery.purge(held[i]);
587
+ }
588
+ }
589
+ // Cleared: every attribute the player is holding, which is the live set the
590
+ // keyframe would write PLUS anything this batch removed before the exclusion
591
+ // landed. Clearing an attribute the player does not have is a no-op; leaving
592
+ // one behind makes the placeholder disagree with a fresh snapshot of the
593
+ // same moment, which is the whole point of the sequence.
594
+ var redacted = isRedactedNow(el, state);
595
+ var names = emittedAttrs(el, redacted);
596
+ var pre = preBatchAttrList(el, state);
597
+ for (var i = 0; i < pre.length; i++) {
598
+ if (attrPatchValue(el, pre[i].name, redacted) === ATTR_WITHHELD) continue;
599
+ names[pre[i].name] = null;
600
+ }
601
+ for (var name in names) {
602
+ if (!Object.prototype.hasOwnProperty.call(names, name)) continue;
603
+ state.out.push({ type: 'dom.attr', t: state.t, node: id, name: name, value: null });
604
+ }
605
+ }
606
+
607
+ // Exclusion attribute REMOVED (spec §4): attributes come back, then the
608
+ // subtree as a freshly numbered `dom.add` per child.
609
+ //
610
+ // "Freshly numbered" means ids the file has never used, and that is what these
611
+ // are: the numbering pass walks hidden subtrees unconditionally, so the
612
+ // children were reserved numbers at the keyframe and emitted under none of
613
+ // them. Reusing those reservations keeps the span monotonic and makes a
614
+ // hide/reveal cycle idempotent — nothing is renumbered, no id is spent twice.
615
+ function restoreFromPlaceholder(el, id, state) {
616
+ var attrs = emittedAttrs(el, isRedactedNow(el, state));
617
+ for (var name in attrs) {
618
+ if (!Object.prototype.hasOwnProperty.call(attrs, name)) continue;
619
+ state.out.push({
620
+ type: 'dom.attr', t: state.t, node: id, name: name, value: attrs[name],
621
+ });
622
+ }
623
+ state.restored.add(el);
624
+ // The reveal re-emits this element's children from the final DOM, so every
625
+ // record about what moved inside it is history the payload already tells.
626
+ state.payloads.add(el);
627
+ if (!carriesChildren(el, false)) return;
628
+ var kids = el.childNodes || [];
629
+ for (var i = 0; i < kids.length; i++) {
630
+ var kid = kids[i];
631
+ // Emittability first: serializing a script here would number it on the way
632
+ // to discarding it, which the insertion path deliberately avoids.
633
+ if (!isEmittableNode(kid)) continue;
634
+ if (state.delivery.holds(kid) && state.delivery.parentOf(kid) === el) continue;
635
+ // A child the file carries somewhere ELSE has to leave that place first,
636
+ // or the reveal hands the player a second copy.
637
+ pullForwardMoves(kid, state);
638
+ var tree = serializeTree(kid, state.span, state.opts);
639
+ if (!tree) continue;
640
+ // The player holds this placeholder EMPTY — transitions run before any
641
+ // insertion is mapped — so appending in document order reproduces it.
642
+ state.out.push({
643
+ type: 'dom.add', t: state.t, parent: id, before: null, node: tree,
644
+ });
645
+ state.payloads.add(kid);
646
+ }
647
+ // The children just became held; nothing may reuse a run recorded for this
648
+ // parent (none can exist yet, but the cache and its invariant stay in sync).
649
+ state.noHeldFrom.delete(el);
650
+ }
651
+
652
+ function mapCharacterData(record, state) {
653
+ var node = record.target;
654
+ if (!state.delivery.holds(node)) return;
655
+ if (nearestEmittedAncestor(node, state.root, state.opts) !== node) return;
656
+ var id = state.registry.peekId(node);
657
+ if (id == null) return;
658
+ // Spec §8: a redacted subtree's content must not appear anywhere in the
659
+ // file. The keyframe already wrote this node as an empty string, so
660
+ // suppressing the patch leaves the player exactly where the snapshot put it.
661
+ // Marking it keeps that true after the node is moved somewhere unredacted.
662
+ if (isRedactedNow(node, state)) { markRedacted(node, state.opts.taint); return; }
663
+
664
+ state.out.push({
665
+ type: 'dom.text', t: state.t, node: id,
666
+ text: String(node.textContent == null ? '' : node.textContent),
667
+ });
668
+ }