browserscale-ts 1.5.0 → 1.7.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.
@@ -0,0 +1,613 @@
1
+ /** Node ids are renderer-local, so the frame is part of the address. */
2
+ const keyOf = (frameId, backendNodeId) => `${frameId}#${backendNodeId}`;
3
+ /**
4
+ * How hard to try to rebuild the page after the browser voids it. A void
5
+ * usually means the page is navigating, and the window where the old document
6
+ * is gone and the new one cannot be serialized yet is short.
7
+ */
8
+ const RESYNC_ATTEMPTS = 4;
9
+ const RESYNC_RETRY_MS = 150;
10
+ /**
11
+ * A live copy of a page's DOM, across every frame in it.
12
+ *
13
+ * Returned by {@link CloudBrowser.mirrorDom}. The browser sends the top of the
14
+ * tree once and from then on only what changed in the part you expanded, so a
15
+ * page that churns inside a collapsed subtree costs one number per batch
16
+ * instead of a re-serialized document.
17
+ *
18
+ * It is one tree. An `<iframe>` is an element whose one child is the document
19
+ * it hosts; expanding it fetches that document and starts mirroring the frame,
20
+ * collapsing it stops again, and a frame navigating arrives as its owner's
21
+ * child being replaced. Underneath there is still one mirror per document,
22
+ * because a mutation observer is bound to a single Document and an
23
+ * out-of-process iframe is a different Document in a different process — but
24
+ * that is engine bookkeeping, not something a caller models.
25
+ *
26
+ * Node ids restart per frame, so a node's address is the pair
27
+ * ({@link DomNode.frameId}, `backendNodeId`) and never the id alone.
28
+ *
29
+ * The tree is treated as immutable: applying a change replaces the nodes from
30
+ * the root down to the one that moved and leaves every other object identical.
31
+ * A UI can therefore re-render from `root` and let `React.memo` (or any
32
+ * identity check) skip the parts that did not move.
33
+ *
34
+ * ```ts
35
+ * const mirror = await browser.mirrorDom({ pierce: true }, () => render(mirror.root));
36
+ * await mirror.expand(bodyNode); // start reporting changes inside <body>
37
+ * await mirror.collapse(bodyNode); // stop again
38
+ * await mirror.stop();
39
+ * ```
40
+ */
41
+ export class DomMirror {
42
+ /** @internal Constructed by CloudBrowser; not part of the public API. */
43
+ constructor(init) {
44
+ this.nodes = new Map();
45
+ this.slots = new Map();
46
+ this.expandedKeys = new Set();
47
+ /**
48
+ * Per node: the page sequence its current state was defined at, by a read
49
+ * payload or by an edit.
50
+ *
51
+ * Reads and events reach a client over two different channels — a unary call
52
+ * and a stream — so an event can turn up that the read reply already folded
53
+ * in. Applying it twice would duplicate an insertion, which is the one entry
54
+ * type that is not idempotent. Comparing against the node's own watermark
55
+ * rather than a single page-wide one keeps that from silently discarding a
56
+ * change to an unrelated part of the tree.
57
+ */
58
+ this.asOf = new Map();
59
+ this.seqValue = 0;
60
+ this.rootNode = null;
61
+ this.mainFrame = "";
62
+ /**
63
+ * Events are held until a snapshot exists to apply them to, and dropped if
64
+ * they predate it. Without this a batch that lands between subscribing and
65
+ * the snapshot arriving would either be applied to nothing or applied twice.
66
+ */
67
+ this.ready = false;
68
+ this.pendingEvents = [];
69
+ this.stopped = false;
70
+ this.failure = null;
71
+ this.transport = init.transport;
72
+ this.options = init.options;
73
+ this.onChange = init.onChange;
74
+ this.onResync = init.onResync;
75
+ this.abort = init.abort;
76
+ this.finished = this.pump(init.stream);
77
+ }
78
+ // ── public surface ────────────────────────────────────────────────────
79
+ /** The main frame's document, or null before the first snapshot arrived. */
80
+ get root() {
81
+ return this.rootNode;
82
+ }
83
+ /** The page's main frame. */
84
+ get mainFrameId() {
85
+ return this.mainFrame;
86
+ }
87
+ /**
88
+ * Every frame with a document in the tree, main frame first. A frame whose
89
+ * `<iframe>` has not been expanded is not mirrored and not listed.
90
+ */
91
+ get frameIds() {
92
+ const seen = new Set();
93
+ if (this.mainFrame)
94
+ seen.add(this.mainFrame);
95
+ for (const node of this.nodes.values()) {
96
+ if (node.nodeType === 9)
97
+ seen.add(node.frameId);
98
+ }
99
+ return [...seen];
100
+ }
101
+ /**
102
+ * The page sequence of the last change applied. One clock for the whole
103
+ * page: a change in an out-of-process iframe and one in the main document
104
+ * are ordered against each other.
105
+ */
106
+ get seq() {
107
+ return this.seqValue;
108
+ }
109
+ /** Looks up a node by its address. */
110
+ getNode(frameId, backendNodeId) {
111
+ return this.nodes.get(keyOf(frameId, backendNodeId));
112
+ }
113
+ /**
114
+ * Whether this node's children are known. Changes inside a node that is not
115
+ * expanded arrive only as an updated `childNodeCount`.
116
+ */
117
+ isExpanded(node) {
118
+ return this.expandedKeys.has(keyOf(node.frameId, node.backendNodeId));
119
+ }
120
+ /** Why the mirror ended: null while running and after a clean stop. */
121
+ get error() {
122
+ return this.failure;
123
+ }
124
+ /**
125
+ * Fetches a node's children and starts reporting changes inside them. This
126
+ * is what a tree view calls when the user opens a node.
127
+ *
128
+ * On an `<iframe>` the one child is the document it hosts, and this call is
129
+ * what starts mirroring that frame. Nothing about the result says a process
130
+ * boundary was crossed; it is a child list like any other.
131
+ *
132
+ * @param depth levels below the node, default 1
133
+ */
134
+ async expand(node, depth) {
135
+ if (this.stopped)
136
+ return;
137
+ const frame = node.frameId;
138
+ const key = keyOf(frame, node.backendNodeId);
139
+ const reply = await this.transport.children(node.backendNodeId, frame, depth);
140
+ // The node can be gone by the time the reply lands — a resync or a removal
141
+ // in between rebuilt the part of the tree this describes.
142
+ if (!this.nodes.has(key))
143
+ return;
144
+ const kids = JSON.parse(reply.children || "[]");
145
+ const fresh = this.touch(key);
146
+ this.dropChildren(fresh);
147
+ fresh.children = kids.map((k) => this.adopt(k, { parent: key, kind: "children" }, frame, reply.seq));
148
+ fresh.childNodeCount = fresh.children.length;
149
+ this.expandedKeys.add(key);
150
+ this.mark(key, reply.seq);
151
+ this.onChange(this);
152
+ }
153
+ /**
154
+ * Stops reporting changes inside a node, called when the user closes it. The
155
+ * node itself stays in the tree and keeps reporting its child count. A child
156
+ * frame below it stops being mirrored too.
157
+ *
158
+ * Skipping this is not an error, it is a slow leak: the browser's revealed
159
+ * set only grows, and eventually it is no longer filtering anything.
160
+ */
161
+ async collapse(node) {
162
+ if (this.stopped)
163
+ return;
164
+ const key = keyOf(node.frameId, node.backendNodeId);
165
+ await this.transport.release(node.backendNodeId, node.frameId);
166
+ if (!this.nodes.has(key))
167
+ return;
168
+ const fresh = this.touch(key);
169
+ this.dropChildren(fresh);
170
+ delete fresh.children;
171
+ this.expandedKeys.delete(key);
172
+ this.onChange(this);
173
+ }
174
+ /**
175
+ * Brings a node into the tree together with its ancestors and their
176
+ * siblings, and starts reporting changes along that path.
177
+ *
178
+ * Use it to focus a node you do not hold — an `inspectAtPosition` hit, say.
179
+ * You cannot walk up to it yourself: it is not in your tree, so there is
180
+ * nothing to walk from.
181
+ *
182
+ * The node may be in a frame nobody opened, and that works: the chain comes
183
+ * back crossing the frame boundaries it has to, and those frames start being
184
+ * mirrored, exactly as if you had expanded your way there by hand.
185
+ *
186
+ * @returns the ancestor chain, the main document first, or an empty array if
187
+ * the node is not on the page
188
+ */
189
+ async reveal(backendNodeId, frameId) {
190
+ if (this.stopped)
191
+ return [];
192
+ const frame = frameId || this.mainFrame;
193
+ const reply = await this.transport.reveal(backendNodeId, frame);
194
+ const path = JSON.parse(reply.path || "[]");
195
+ if (path.length === 0)
196
+ return [];
197
+ // The chain starts at the main document, which the mirror already holds,
198
+ // so it is merged level by level rather than re-rooted. Re-rooting would
199
+ // throw away the rest of the page, including every other frame opened
200
+ // into it.
201
+ const keys = [];
202
+ let space = this.mainFrame;
203
+ let above = null;
204
+ for (const step of path) {
205
+ // A document names its own frame, and everything after it counts in
206
+ // that frame's ids. This is the boundary, and it is the only place the
207
+ // address space changes.
208
+ if (step.nodeType === 9 && step.frameId)
209
+ space = step.frameId;
210
+ const key = keyOf(space, step.backendNodeId);
211
+ if (!this.nodes.has(key)) {
212
+ // The only step that can be missing is a hosted document: every other
213
+ // one arrived in the child list of the step above it. Hanging it off
214
+ // its owner is what crossing into the frame means here.
215
+ if (above === null || !this.nodes.has(above))
216
+ break;
217
+ const owner = this.touch(above);
218
+ this.dropChildren(owner);
219
+ owner.children = [
220
+ this.adopt(step, { parent: above, kind: "children" }, space, reply.seq),
221
+ ];
222
+ owner.childNodeCount = 1;
223
+ this.expandedKeys.add(above);
224
+ this.mark(above, reply.seq);
225
+ }
226
+ this.mergeChildren(key, step, space, reply.seq);
227
+ keys.push(key);
228
+ above = key;
229
+ }
230
+ this.onChange(this);
231
+ return keys
232
+ .map((k) => this.nodes.get(k))
233
+ .filter((n) => n !== undefined);
234
+ }
235
+ /**
236
+ * Throws away the local copy of the whole page and fetches a fresh one.
237
+ * Happens automatically whenever the browser says the copy is void, so you
238
+ * rarely need to call it.
239
+ */
240
+ async resync(reason = "manual") {
241
+ if (this.stopped)
242
+ return;
243
+ this.ready = false;
244
+ // A resync that fails leaves nothing usable behind: the local copy is
245
+ // already declared void and no more events will make sense against it. The
246
+ // common cause is transient — the page was voided because it is navigating,
247
+ // and the new document is not there yet to be serialized — so this retries
248
+ // before treating it as fatal.
249
+ let snapshot = null;
250
+ let last;
251
+ for (let attempt = 0; attempt < RESYNC_ATTEMPTS; attempt++) {
252
+ if (this.stopped)
253
+ return;
254
+ if (attempt > 0)
255
+ await new Promise((r) => setTimeout(r, RESYNC_RETRY_MS));
256
+ try {
257
+ snapshot = await this.transport.start(this.options);
258
+ break;
259
+ }
260
+ catch (err) {
261
+ last = err;
262
+ }
263
+ }
264
+ if (!snapshot) {
265
+ // Out of options. Ending the mirror is the point: a caller watching
266
+ // wait() can say the tree is frozen, where staying quietly broken looks
267
+ // exactly like a page on which nothing is happening.
268
+ this.failure = last instanceof Error ? last : new Error(String(last));
269
+ void this.stop();
270
+ return;
271
+ }
272
+ this.install(snapshot);
273
+ this.onResync?.(reason);
274
+ this.onChange(this);
275
+ }
276
+ /** Resolves once the mirror ends — stop(), a dead session, a transport failure. */
277
+ async wait() {
278
+ await this.finished;
279
+ if (this.failure)
280
+ throw this.failure;
281
+ }
282
+ /** Stops mirroring and detaches the reader. Idempotent, safe in a `finally`. */
283
+ async stop() {
284
+ if (this.stopped)
285
+ return;
286
+ this.stopped = true;
287
+ try {
288
+ await this.transport.stop();
289
+ }
290
+ catch {
291
+ // The local reader goes down either way, and a page that is already gone
292
+ // is exactly when this throws.
293
+ }
294
+ finally {
295
+ this.abort.abort();
296
+ await this.finished;
297
+ }
298
+ }
299
+ // ── snapshot installation ─────────────────────────────────────────────
300
+ /** @internal Called by CloudBrowser with the opening snapshot. */
301
+ install(snapshot) {
302
+ this.reset();
303
+ this.mainFrame = snapshot.frameId || this.mainFrame;
304
+ this.seqValue = snapshot.seq;
305
+ const parsed = snapshot.root
306
+ ? JSON.parse(snapshot.root)
307
+ : null;
308
+ this.rootNode = parsed
309
+ ? this.adopt(parsed, null, this.mainFrame, snapshot.seq)
310
+ : null;
311
+ this.ready = true;
312
+ // Anything buffered while the snapshot was in flight is either already in
313
+ // it (seq <= snapshot) or genuinely newer. Either way the watermark check
314
+ // in handle() decides, so replaying the buffer here is safe.
315
+ const buffered = this.pendingEvents;
316
+ this.pendingEvents = [];
317
+ for (const event of buffered)
318
+ this.handle(event);
319
+ }
320
+ reset() {
321
+ this.nodes = new Map();
322
+ this.slots = new Map();
323
+ this.expandedKeys = new Set();
324
+ this.asOf = new Map();
325
+ this.rootNode = null;
326
+ }
327
+ // ── event stream ──────────────────────────────────────────────────────
328
+ async pump(stream) {
329
+ try {
330
+ for await (const event of stream) {
331
+ if (event.event.case === "resync") {
332
+ // Awaited, so the events behind it are processed against the tree it
333
+ // produces rather than against the one it just invalidated.
334
+ await this.resync(event.event.value.reason);
335
+ continue;
336
+ }
337
+ this.handle(event);
338
+ }
339
+ }
340
+ catch (err) {
341
+ // Cancelling is how stop() ends the stream, so that error is expected.
342
+ if (!this.stopped) {
343
+ this.failure = err instanceof Error ? err : new Error(String(err));
344
+ }
345
+ }
346
+ }
347
+ handle(event) {
348
+ if (!this.ready) {
349
+ if (event.event.case !== undefined)
350
+ this.pendingEvents.push(event);
351
+ return;
352
+ }
353
+ if (event.event.case !== "update")
354
+ return;
355
+ const batch = event.event.value;
356
+ const frame = batch.frameId || this.mainFrame;
357
+ const seq = Number(batch.seq);
358
+ let entries;
359
+ try {
360
+ entries = JSON.parse(batch.edits || "[]");
361
+ }
362
+ catch {
363
+ void this.resync("overflow");
364
+ return;
365
+ }
366
+ // Which entries to keep is decided against the watermarks as they stand
367
+ // BEFORE the batch, and the watermarks are moved only afterwards.
368
+ // Entries within a batch share its sequence, and a frame swap is exactly
369
+ // a removal and an insertion on the same owner in one batch — judging the
370
+ // second against a watermark the first just raised would drop it.
371
+ const keep = [];
372
+ const touched = [];
373
+ for (const entry of entries) {
374
+ const key = keyOf(frame, targetOf(entry));
375
+ if ((this.asOf.get(key) ?? -1) >= seq)
376
+ continue;
377
+ keep.push(entry);
378
+ touched.push(key);
379
+ }
380
+ for (const entry of keep)
381
+ this.apply(entry, frame, seq);
382
+ for (const key of touched)
383
+ this.mark(key, seq);
384
+ if (seq > this.seqValue)
385
+ this.seqValue = seq;
386
+ this.onChange(this);
387
+ }
388
+ // ── applying one entry ────────────────────────────────────────────────
389
+ apply(entry, frame, seq) {
390
+ switch (entry.type) {
391
+ case "childNodeInserted": {
392
+ const parentKey = keyOf(frame, entry.parentId);
393
+ if (!this.nodes.has(parentKey))
394
+ return;
395
+ const fresh = this.touch(parentKey);
396
+ // The browser only reports insertions for nodes it expanded. If we do
397
+ // not have a child list for one, it had none when we last saw it, so
398
+ // an empty list is the right thing to grow from.
399
+ const kids = fresh.children ?? [];
400
+ const node = this.adopt(entry.node, { parent: parentKey, kind: "children" }, frame, seq);
401
+ const at = entry.previousNodeId === 0
402
+ ? 0
403
+ : kids.findIndex((c) => c.backendNodeId === entry.previousNodeId) + 1;
404
+ kids.splice(at <= 0 ? 0 : at, 0, node);
405
+ fresh.children = kids;
406
+ fresh.childNodeCount = kids.length;
407
+ this.expandedKeys.add(parentKey);
408
+ return;
409
+ }
410
+ case "childNodeRemoved": {
411
+ const parentKey = keyOf(frame, entry.parentId);
412
+ const parent = this.nodes.get(parentKey);
413
+ if (!parent?.children)
414
+ return;
415
+ const fresh = this.touch(parentKey);
416
+ const kids = fresh.children ?? [];
417
+ // Matched on the id alone, not the pair. When the child is a hosted
418
+ // document the id is in ITS frame, not the parent's — but a frame
419
+ // owner has exactly one child, so there is nothing to confuse it with.
420
+ const at = kids.findIndex((c) => c.backendNodeId === entry.nodeId);
421
+ if (at < 0)
422
+ return;
423
+ this.forget(kids[at]);
424
+ kids.splice(at, 1);
425
+ fresh.children = kids;
426
+ fresh.childNodeCount = kids.length;
427
+ return;
428
+ }
429
+ case "childNodeCountUpdated": {
430
+ const key = keyOf(frame, entry.nodeId);
431
+ if (!this.nodes.has(key))
432
+ return;
433
+ this.touch(key).childNodeCount = entry.count;
434
+ return;
435
+ }
436
+ case "childListReordered": {
437
+ const key = keyOf(frame, entry.nodeId);
438
+ const parent = this.nodes.get(key);
439
+ if (!parent?.children)
440
+ return;
441
+ const fresh = this.touch(key);
442
+ const have = new Map((fresh.children ?? []).map((c) => [c.backendNodeId, c]));
443
+ const next = [];
444
+ for (const id of entry.order) {
445
+ const child = have.get(id);
446
+ if (child)
447
+ next.push(child);
448
+ }
449
+ // The browser sends the complete order, so a mismatch means our copy
450
+ // drifted. Keeping the strays would hide that; dropping the batch and
451
+ // rebuilding is the only honest option.
452
+ if (next.length !== have.size) {
453
+ void this.resync("overflow");
454
+ return;
455
+ }
456
+ fresh.children = next;
457
+ return;
458
+ }
459
+ case "attributesUpdated": {
460
+ const key = keyOf(frame, entry.nodeId);
461
+ if (!this.nodes.has(key))
462
+ return;
463
+ this.touch(key).attributes = entry.attributes.slice();
464
+ return;
465
+ }
466
+ case "characterDataModified": {
467
+ const key = keyOf(frame, entry.nodeId);
468
+ if (!this.nodes.has(key))
469
+ return;
470
+ this.touch(key).nodeValue = entry.value;
471
+ return;
472
+ }
473
+ }
474
+ }
475
+ // ── tree bookkeeping ──────────────────────────────────────────────────
476
+ /** Records that a node's state is current as of `seq`. Never moves back. */
477
+ mark(key, seq) {
478
+ if (seq > (this.asOf.get(key) ?? -1))
479
+ this.asOf.set(key, seq);
480
+ if (seq > this.seqValue)
481
+ this.seqValue = seq;
482
+ }
483
+ /**
484
+ * Replaces `key` and every ancestor with copies, so the path from the root
485
+ * to the changed node has new identities and nothing else does. Returns the
486
+ * fresh copy of `key`, which the caller then edits in place.
487
+ *
488
+ * The walk crosses frame boundaries without noticing them: a document is a
489
+ * child of the `<iframe>` hosting it like any other, so a change deep inside
490
+ * an out-of-process frame still produces a new `root`.
491
+ */
492
+ touch(key) {
493
+ const original = this.nodes.get(key);
494
+ if (!original)
495
+ throw new Error(`dom mirror: node ${key} is not in the tree`);
496
+ const clone = { ...original };
497
+ if (original.children)
498
+ clone.children = original.children.slice();
499
+ if (original.shadowRoots)
500
+ clone.shadowRoots = original.shadowRoots.slice();
501
+ this.nodes.set(key, clone);
502
+ let childKey = key;
503
+ let childNode = clone;
504
+ for (;;) {
505
+ const slot = this.slots.get(childKey);
506
+ if (!slot) {
507
+ this.rootNode = childNode;
508
+ return clone;
509
+ }
510
+ const parent = this.nodes.get(slot.parent);
511
+ if (!parent)
512
+ return clone;
513
+ const parentClone = { ...parent };
514
+ const list = (parent[slot.kind] ?? []).slice();
515
+ const at = list.findIndex((n) => n.backendNodeId === childNode.backendNodeId &&
516
+ n.frameId === childNode.frameId);
517
+ if (at >= 0)
518
+ list[at] = childNode;
519
+ parentClone[slot.kind] = list;
520
+ this.nodes.set(slot.parent, parentClone);
521
+ childKey = slot.parent;
522
+ childNode = parentClone;
523
+ }
524
+ }
525
+ /**
526
+ * Registers a payload subtree and returns the copy that lives in the tree.
527
+ *
528
+ * `frame` is the id space the payload's ids belong to, and it changes here
529
+ * and nowhere else: a document node names its own frame, and everything
530
+ * below it counts in that frame. That is the whole of what crossing into an
531
+ * iframe means to a client.
532
+ */
533
+ adopt(node, slot, frame, seq) {
534
+ // The browser puts the frame's own id on a document and the HOSTED
535
+ // frame's id on a frame owner element. Same field, two relationships —
536
+ // which is which is decided by the node type, and only one of them can be
537
+ // called frameId without making "which frame is this node in" ambiguous.
538
+ const isDocument = node.nodeType === 9;
539
+ const own = isDocument && node.frameId ? node.frameId : frame;
540
+ const key = keyOf(own, node.backendNodeId);
541
+ const copy = { ...node, frameId: own };
542
+ if (!isDocument && node.frameId && node.frameId !== own) {
543
+ copy.contentFrameId = node.frameId;
544
+ }
545
+ else {
546
+ delete copy.contentFrameId;
547
+ }
548
+ this.nodes.set(key, copy);
549
+ this.mark(key, seq);
550
+ if (slot)
551
+ this.slots.set(key, slot);
552
+ else
553
+ this.slots.delete(key);
554
+ if (node.children) {
555
+ copy.children = node.children.map((c) => this.adopt(c, { parent: key, kind: "children" }, own, seq));
556
+ this.expandedKeys.add(key);
557
+ }
558
+ else {
559
+ delete copy.children;
560
+ this.expandedKeys.delete(key);
561
+ }
562
+ if (node.shadowRoots) {
563
+ copy.shadowRoots = node.shadowRoots.map((r) => this.adopt(r, { parent: key, kind: "shadowRoots" }, own, seq));
564
+ }
565
+ else {
566
+ delete copy.shadowRoots;
567
+ }
568
+ return copy;
569
+ }
570
+ /** Replaces a node's child list from a fresh payload for the same node. */
571
+ mergeChildren(key, payload, frame, seq) {
572
+ if (!payload.children)
573
+ return;
574
+ const fresh = this.touch(key);
575
+ this.dropChildren(fresh);
576
+ fresh.children = payload.children.map((c) => this.adopt(c, { parent: key, kind: "children" }, frame, seq));
577
+ fresh.childNodeCount = fresh.children.length;
578
+ this.expandedKeys.add(key);
579
+ this.mark(key, seq);
580
+ }
581
+ dropChildren(node) {
582
+ for (const child of node.children ?? [])
583
+ this.forget(child);
584
+ }
585
+ /**
586
+ * Removes a subtree from the index. It descends through hosted documents
587
+ * like through anything else — they are children, and a frame stops being
588
+ * mirrored exactly when the element hosting it stops being expanded.
589
+ */
590
+ forget(node) {
591
+ const key = keyOf(node.frameId, node.backendNodeId);
592
+ this.nodes.delete(key);
593
+ this.slots.delete(key);
594
+ this.expandedKeys.delete(key);
595
+ this.asOf.delete(key);
596
+ for (const child of node.children ?? [])
597
+ this.forget(child);
598
+ for (const root of node.shadowRoots ?? [])
599
+ this.forget(root);
600
+ }
601
+ }
602
+ // The node an entry is about, which for the two child-list entries is the
603
+ // PARENT: that is the node whose state they change, and whose watermark
604
+ // therefore decides whether they have already been folded in.
605
+ function targetOf(entry) {
606
+ switch (entry.type) {
607
+ case "childNodeInserted":
608
+ case "childNodeRemoved":
609
+ return entry.parentId;
610
+ default:
611
+ return entry.nodeId;
612
+ }
613
+ }