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.
- package/CHANGELOG.md +51 -0
- package/CITATION.cff +2 -2
- package/README.md +21 -6
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +3 -2
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/package.json +10 -2
- package/src/cli/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +66 -12
- package/src/cli/renderers/html-index.js +7 -5
- package/src/cli/renderers/replay-assets.js +61 -7
- package/src/cli/renderers/replay-client-source.js +102 -0
- package/src/cli/renderers/replay-viewer.client.js +1750 -595
- package/src/cli/report.js +6 -0
- package/src/core/monitor.js +12 -13
- package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/replay/capture-dom.js +470 -458
- package/src/replay/capture-trace.js +688 -271
- package/src/replay/delivery.js +82 -0
- package/src/replay/dom-instantiate.js +779 -0
- package/src/replay/index.js +88 -5
- package/src/replay/initial-state.js +295 -0
- package/src/replay/mutations.js +668 -0
- package/src/replay/node-registry.js +116 -0
- package/src/replay/persistence.js +19 -6
- package/src/replay/recorder.js +342 -73
- package/src/replay/redaction.js +165 -0
- package/src/replay/serializer.js +148 -43
- package/src/replay/snapshot.js +409 -0
- package/src/replay/span.js +55 -0
- package/src/replay/viewer-model.js +293 -102
- package/src/shared/constants.js +1 -1
- package/src/shared/inline-safe.js +80 -0
- package/src/shared/schema-v2-validator.js +595 -0
- 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
|
+
}
|