rusteerio 1.0.1 → 1.1.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 (57) hide show
  1. package/Cargo.lock +42 -1
  2. package/Cargo.toml +8 -1
  3. package/README.md +90 -51
  4. package/THIRD_PARTY_NOTICES.md +107 -2
  5. package/dist/{chunk-DMRD3FGC.mjs → chunk-FPRP6UIX.mjs} +2233 -751
  6. package/dist/chunk-FPRP6UIX.mjs.map +1 -0
  7. package/dist/index.cjs +2232 -750
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.mts +2 -2
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.mjs +1 -1
  12. package/dist/slim.cjs +2226 -745
  13. package/dist/slim.cjs.map +1 -1
  14. package/dist/slim.d.mts +2 -2
  15. package/dist/slim.d.ts +2 -2
  16. package/dist/slim.mjs +1 -1
  17. package/dist/{types-BP6TpxE5.d.mts → types-Cs9UP1Vv.d.mts} +3 -3
  18. package/dist/{types-BP6TpxE5.d.ts → types-Cs9UP1Vv.d.ts} +3 -3
  19. package/dist/utils.d.mts +1 -1
  20. package/dist/utils.d.ts +1 -1
  21. package/native/src/arena.rs +219 -80
  22. package/native/src/attributes.rs +11 -5
  23. package/native/src/document_order.rs +711 -0
  24. package/native/src/extraction.rs +15 -3
  25. package/native/src/gc.rs +12 -3
  26. package/native/src/id_hash.rs +42 -0
  27. package/native/src/immutable.rs +256 -0
  28. package/native/src/lib.rs +1039 -171
  29. package/native/src/runtime.rs +189 -33
  30. package/native/src/selector.rs +346 -44
  31. package/native/src/sink.rs +14 -1
  32. package/native/src/snapshot.rs +509 -0
  33. package/native/src/stream.rs +2 -2
  34. package/native/src/tokenizer.rs +29 -12
  35. package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +79 -0
  36. package/package.json +3 -1
  37. package/rusteerio.darwin-arm64.node +0 -0
  38. package/rusteerio.darwin-x64.node +0 -0
  39. package/rusteerio.linux-arm64-gnu.node +0 -0
  40. package/rusteerio.linux-arm64-musl.node +0 -0
  41. package/rusteerio.linux-x64-gnu.node +0 -0
  42. package/rusteerio.linux-x64-musl.node +0 -0
  43. package/rusteerio.win32-arm64-msvc.node +0 -0
  44. package/rusteerio.win32-x64-msvc.node +0 -0
  45. package/src/attributes.ts +78 -44
  46. package/src/codec.ts +22 -3
  47. package/src/extract.ts +86 -2
  48. package/src/hooks.ts +148 -2
  49. package/src/index.ts +51 -40
  50. package/src/match.ts +207 -0
  51. package/src/native.ts +9 -1
  52. package/src/nodes.ts +531 -183
  53. package/src/router.ts +792 -229
  54. package/src/selection.ts +239 -125
  55. package/src/snapshot.ts +255 -0
  56. package/src/transport.ts +51 -42
  57. package/dist/chunk-DMRD3FGC.mjs.map +0 -1
package/src/router.ts CHANGED
@@ -1,328 +1,891 @@
1
- import { requireNative, type NativeDocument, type NativeNodeRecord } from './native.js';
1
+ import { packageDirectory, requireNative, type NativeDocument, type NativeNodeRecord } from './native.js';
2
2
  import { hasParserHooks, parseNativeDocument } from './hooks.js';
3
3
  import type { CheerioOptions } from './types.js';
4
+ import { NodeSnapshot, plainRecord, type NativeSnapshot } from './snapshot.js';
4
5
 
5
6
  interface NativeMemoryStats { live: number; slots: number; retained: number; reclaimed: number; immutableNodes: number; externalBytes: number }
7
+ interface PinnedCollection extends NativeMemoryStats { free: Uint32Array }
6
8
  interface ManagedNativeDocument extends NativeDocument {
7
- retain?(ids: number[]): void;
8
- release?(ids: number[]): void;
9
+ setAttribute?(ids: number[], name: string, value: string | null): void;
10
+ slotCount?(): number;
11
+ routingIndex?(): number;
12
+ detachedSlots?(root: number): Uint32Array;
13
+ render?(ids: number[], optionsJson?: string): string;
14
+ snapshot?(): NativeSnapshot;
15
+ takeTouched?(): Uint32Array | null;
16
+ collectPinned?(pins: Uint32Array, keepBelow?: number): PinnedCollection;
9
17
  collectGarbage?(): NativeMemoryStats;
18
+ isCollectionDue?(): boolean;
10
19
  }
11
- interface PinRecord { global: number; document: WeakRef<ManagedNativeDocument>; local: number }
12
- interface Handle { global: number; document: ManagedNativeDocument; local: number; data?: object; pin: PinRecord }
13
- interface HandleEntry { reference: WeakRef<Handle>; pin: PinRecord }
20
+
21
+ /**
22
+ * A node id is `state.base + local`, where `local` indexes the node in its
23
+ * native arena. Ids from different documents never collide, so every routed
24
+ * document shares one id space without a lookup table per node.
25
+ */
26
+ const STRIDE = 2 ** 32;
27
+ /** Ids must stay exact doubles, which bounds how many documents may be alive at once. */
28
+ const maximumDocuments = 2 ** 21;
29
+ /** A document's root is its first slot; collections keep it and everything attached to it. */
30
+ const rootSlots = 1;
31
+
32
+ /**
33
+ * Ids held by a JavaScript owner. Collection keeps them; moves rewrite them in
34
+ * place. The finalization registry holds only the token, which references
35
+ * nothing: its cleanup runs only once the event loop turns, and anything it
36
+ * held would keep documents of a synchronous loop alive until then.
37
+ */
38
+ export interface Lease { ids: number[]; token: LeaseToken; states: DocumentState[] }
39
+ interface LeaseToken { isReleased: boolean }
40
+
41
+ /**
42
+ * JavaScript `.data()` storage for one node; moves carry it to the node's new
43
+ * arena. A collection that frees the node releases it, so data objects still
44
+ * held no longer write to whatever node reuses the slot.
45
+ */
46
+ export interface DataEntry { state: DocumentState; local: number; data?: object; isReleased?: boolean }
47
+
48
+ /** Bookkeeping for one native document. */
49
+ export interface DocumentState {
50
+ readonly raw: ManagedNativeDocument;
51
+ readonly base: number;
52
+ /** The id space the state's base belongs to: another copy of the package numbers its documents separately. */
53
+ readonly space: object;
54
+ readonly options: CheerioOptions;
55
+ rootLocal: number | undefined;
56
+ routed: RoutedDocument | undefined;
57
+ generation: number;
58
+ records: (NativeNodeRecord | null | undefined)[];
59
+ bulk: NodeSnapshot | number;
60
+ snapshotSize: number;
61
+ /** Nodes whose snapshot entry is outdated by a targeted write. */
62
+ stale: Set<number>;
63
+ /**
64
+ * Node objects for slots that existed at parse time, until a collection
65
+ * finds their nodes detached; an array read is far cheaper than a map
66
+ * lookup.
67
+ */
68
+ views: (object | undefined)[];
69
+ /** Node objects for slots created later, until a collection finds their nodes detached. */
70
+ looseViews: Map<number, object>;
71
+ /** Size of `looseViews` at which a collection is scheduled. */
72
+ looseViewLimit: number;
73
+ /** Node objects for detached nodes, kept only while something else holds them. */
74
+ weakViews: Map<number, WeakRef<object>>;
75
+ /** Slots that existed when the document was parsed. */
76
+ baseline: number;
77
+ /** Later collections to run while weakened node objects may still become unreachable. */
78
+ collectionRetries: number;
79
+ /** Whether a collection was due but the document left the pending list; its next use schedules it again. */
80
+ isCollectionEvicted: boolean;
81
+ leases: Set<Lease>;
82
+ /** Lease count at which released leases are swept. */
83
+ leaseSweepSize: number;
84
+ data: Map<number, DataEntry>;
85
+ /**
86
+ * Whether the id table resolves this document. Only ids that reach another
87
+ * document need it, and the `WeakRef` it takes keeps the document alive
88
+ * until the current job ends.
89
+ */
90
+ isExposed: boolean;
91
+ /**
92
+ * Documents whose ids this document's selections or leases may hold. A
93
+ * document's index is reused once it is collected, so holders keep it.
94
+ */
95
+ /** Some node object holds a child array that must follow structural writes. */
96
+ hasChildLists: boolean;
97
+ /** A tree adapter's nodes not yet returned to the caller, by slot. */
98
+ adapterNodes?: (object | undefined)[];
99
+ }
100
+
101
+ /** Fields on node objects; registered symbols so ESM and CJS copies share nodes. */
102
+ export const VIEW_STATE = Symbol.for('rusteerio.node.state');
103
+ export const VIEW_LOCAL = Symbol.for('rusteerio.node.local');
104
+ export const VIEW_ATTRIBUTES = Symbol.for('rusteerio.node.attributes');
105
+ /** Points at the node itself, so a spread copy of a node is not mistaken for it. */
106
+ export const VIEW_SELF = Symbol.for('rusteerio.node.self');
107
+ // Internals are own properties under symbols: reads stay plain property
108
+ // loads, which prototype accessors are not on selections of many documents'
109
+ // classes, and selections enumerate like Cheerio's. Registered symbols let
110
+ // the ESM and CommonJS builds read each other's selections.
111
+ export const DOCUMENT: unique symbol = Symbol.for('rusteerio.selection.document');
112
+ export const API: unique symbol = Symbol.for('rusteerio.selection.api');
113
+ export const IDS: unique symbol = Symbol.for('rusteerio.selection.ids');
114
+ export const VALUES: unique symbol = Symbol.for('rusteerio.selection.values');
115
+ export const LEASE: unique symbol = Symbol.for('rusteerio.selection.lease');
116
+ export const IS_PLAIN: unique symbol = Symbol.for('rusteerio.selection.is-plain');
117
+ /** A node object's materialized `children` array, refilled when the node's children change. */
118
+ export const VIEW_CHILD_TARGET = Symbol.for('rusteerio.node.child-target');
119
+
14
120
  interface Registry {
15
- next: number;
16
121
  space: object;
17
- handles: Map<number, WeakRef<Handle>>;
18
- byDocument: WeakMap<NativeDocument, Map<number, HandleEntry>>;
19
- finalizer: FinalizationRegistry<PinRecord>;
20
- snapshots: WeakMap<NativeDocument, Map<number, NativeNodeRecord | null>>;
122
+ /** Writes to any document, so callers can tell whether values they read earlier may be stale. */
123
+ writes: number;
124
+ states: (WeakRef<DocumentState> | undefined)[];
125
+ pending: Set<DocumentState>;
126
+ isScheduled: boolean;
127
+ leaseFinalizer: FinalizationRegistry<LeaseToken>;
21
128
  }
22
- const registryKey = Symbol.for('rusteerio.native-handles.v2');
129
+ // Each installed copy of the package loads its own addon, which numbers its
130
+ // documents independently, so only entry points of one copy share ids.
131
+ const registryKey = Symbol.for(`rusteerio.native-documents.v5:${packageDirectory()}`);
132
+ /** A native document's state lives on the document itself, so they share one lifetime. */
133
+ const stateKey = Symbol.for('rusteerio.native-documents.state');
23
134
  const globals = globalThis as typeof globalThis & { [key: symbol]: Registry | undefined };
24
135
  const registry: Registry = globals[registryKey] ??= (() => {
25
- const handles = new Map<number, WeakRef<Handle>>();
26
- const byDocument = new WeakMap<NativeDocument, Map<number, HandleEntry>>();
27
- const snapshots = new WeakMap<NativeDocument, Map<number, NativeNodeRecord | null>>();
28
- const pending: PinRecord[] = [];
29
- let scheduled = false;
30
- const flush = () => {
31
- scheduled = false;
32
- const batches = new Map<ManagedNativeDocument, number[]>();
33
- for (const pin of pending.splice(0)) {
34
- const document = pin.document.deref();
35
- if (!document) continue;
36
- const entries = byDocument.get(document);
37
- if (entries?.get(pin.local)?.pin === pin) entries.delete(pin.local);
38
- const ids = batches.get(document) ?? [];
39
- ids.push(pin.local); batches.set(document, ids);
40
- }
41
- for (const [document, ids] of batches) {
42
- snapshots.delete(document);
43
- document.release?.(ids);
44
- document.collectGarbage?.();
45
- }
136
+ const created: Registry = {
137
+ space: {},
138
+ writes: 0,
139
+ states: [],
140
+ pending: new Set(),
141
+ isScheduled: false,
142
+ leaseFinalizer: new FinalizationRegistry((token) => { token.isReleased = true; }),
46
143
  };
47
- return { next: 1, space: {}, handles, byDocument, snapshots, finalizer: new FinalizationRegistry<PinRecord>((pin) => {
48
- if (!handles.get(pin.global)?.deref()) handles.delete(pin.global);
49
- pending.push(pin);
50
- if (!scheduled) { scheduled = true; queueMicrotask(flush); }
51
- }) };
144
+ return created;
52
145
  })();
53
146
 
147
+ /** The states owning `ids`, resolved through `document`. */
148
+ function statesOf(document: NativeDocument, ids: readonly number[]): DocumentState[] {
149
+ const states: DocumentState[] = [];
150
+ let previous: DocumentState | undefined;
151
+ for (const id of ids) {
152
+ // Selections may hold holes after direct index writes.
153
+ if (id === undefined) continue;
154
+ if (previous && id >= previous.base && id < previous.base + STRIDE) continue;
155
+ const state = stateIn(document, id);
156
+ if (!states.includes(state)) states.push(state);
157
+ previous = state;
158
+ }
159
+ return states;
160
+ }
161
+
162
+ /**
163
+ * Collects once a structural write leaves the arena grown by half with nodes
164
+ * removed; collecting sooner costs more than the garbage it would free.
165
+ */
166
+ function scheduleCollectionIfDue(state: DocumentState): void {
167
+ if (state.raw.isCollectionDue?.()) scheduleCollection(state);
168
+ }
169
+
170
+ /**
171
+ * Collections run on a later turn of the event loop: no library call is then
172
+ * between native writes holding ids of nodes it has just detached, and node
173
+ * objects weakened on an earlier turn may have been reclaimed.
174
+ */
175
+ function scheduleCollection(state: DocumentState): void {
176
+ registry.pending.add(state);
177
+ // Pending documents are held strongly until the event loop turns; in a
178
+ // synchronous loop only the latest few stay pinned. Holding the others
179
+ // weakly would pin them too: a WeakRef keeps its target until the
180
+ // synchronous job ends. An evicted document is scheduled again on its next
181
+ // use; one never used again is either collected whole or idle.
182
+ if (registry.pending.size > maximumPendingCollections) {
183
+ const evicted = registry.pending.values().next().value!;
184
+ registry.pending.delete(evicted);
185
+ evicted.isCollectionEvicted = true;
186
+ }
187
+ if (registry.isScheduled) return;
188
+ registry.isScheduled = true;
189
+ setImmediate(() => {
190
+ registry.isScheduled = false;
191
+ const states = [...registry.pending];
192
+ registry.pending.clear();
193
+ for (const pending of states) collectState(pending);
194
+ }).unref();
195
+ }
196
+
197
+ const maximumPendingCollections = 4;
198
+
199
+ /** Increases with every write to any document. */
200
+ export function writeCount(): number {
201
+ return registry.writes;
202
+ }
203
+
204
+ /**
205
+ * The state of a native document, created on first use. The addon hands out
206
+ * routing indexes and takes one back when its document is finalized, which
207
+ * happens during garbage collection, so indexes recycle even within a
208
+ * synchronous loop.
209
+ */
210
+ export function stateFor(raw: NativeDocument, options: CheerioOptions = {}): DocumentState {
211
+ const holder = raw as unknown as { [stateKey]?: DocumentState };
212
+ const existing = holder[stateKey];
213
+ if (existing) return existing;
214
+ const index = (raw as ManagedNativeDocument).routingIndex!();
215
+ if (index >= maximumDocuments) throw new RangeError('Too many documents are alive at once.');
216
+ const baseline = (raw as ManagedNativeDocument).slotCount?.() ?? 0;
217
+ const state: DocumentState = {
218
+ raw: raw as ManagedNativeDocument, base: index * STRIDE, space: registry.space, options, rootLocal: undefined, routed: undefined,
219
+ // Sized up front: node objects are cached out of order, and regrowing a holey array is costly.
220
+ generation: 0, records: [], bulk: 0, snapshotSize: 0, stale: new Set(), views: new Array(baseline), looseViews: new Map(), looseViewLimit: minimumLooseViewLimit, weakViews: new Map(), baseline,
221
+ leases: new Set(), leaseSweepSize: 64, data: new Map(), isExposed: false, hasChildLists: false, collectionRetries: 0, isCollectionEvicted: false,
222
+ };
223
+ holder[stateKey] = state;
224
+ return state;
225
+ }
226
+
227
+ /** Lets ids of `state` be resolved from documents other than its own. */
228
+ function exposeState(state: DocumentState): void {
229
+ if (state.isExposed) return;
230
+ state.isExposed = true;
231
+ registry.states[state.base / STRIDE] = new WeakRef(state);
232
+ }
233
+
234
+ /**
235
+ * Called before ids of `state` enter operations or selections of `document`.
236
+ * Leases keep the documents of the ids a selection holds; node objects keep
237
+ * their own.
238
+ */
239
+ export function exposeTo(document: NativeDocument, state: DocumentState): void {
240
+ if ((document as Partial<RoutedDocument>).state === state) return;
241
+ exposeState(state);
242
+ }
243
+
244
+ /** Called before a selection's ids enter operations of `document`. */
245
+ export function exposeDocumentTo(document: NativeDocument, source: NativeDocument | undefined): void {
246
+ const state = (source as Partial<RoutedDocument> | undefined)?.state;
247
+ if (state) exposeTo(document, state);
248
+ }
249
+
250
+ /** The node object cached for a slot, if any. */
251
+ export function cachedView(state: DocumentState, local: number): object | undefined {
252
+ const view = local < state.baseline ? state.views[local] : state.looseViews.get(local);
253
+ return view ?? (state.weakViews.size ? state.weakViews.get(local)?.deref() : undefined);
254
+ }
255
+
256
+ /** Created nodes held as objects only become collectable through a collection. */
257
+ const minimumLooseViewLimit = 1024;
258
+
259
+ export function cacheView(state: DocumentState, local: number, view: object): void {
260
+ if (local < state.baseline) { state.views[local] = view; return; }
261
+ state.looseViews.set(local, view);
262
+ if (state.looseViews.size >= state.looseViewLimit) {
263
+ state.looseViewLimit = state.looseViews.size * 2;
264
+ scheduleCollection(state);
265
+ }
266
+ }
267
+
268
+ function uncacheView(state: DocumentState, local: number): object | undefined {
269
+ const view = cachedView(state, local);
270
+ if (local < state.baseline) state.views[local] = undefined;
271
+ state.looseViews.delete(local);
272
+ state.weakViews.delete(local);
273
+ return view;
274
+ }
275
+
276
+ /** A collection drops released slots at the arena's end; later nodes reuse their indexes. */
277
+ function releaseSlotsFrom(state: DocumentState, end: number): void {
278
+ for (const local of state.data.keys()) if (local >= end) releaseSlot(state, local);
279
+ for (const local of state.weakViews.keys()) if (local >= end) state.weakViews.delete(local);
280
+ if (state.adapterNodes && state.adapterNodes.length > end) {
281
+ for (let local = end; local < state.adapterNodes.length; local++) releaseAdapterNode(state, local);
282
+ state.adapterNodes.length = end;
283
+ }
284
+ }
285
+
286
+ /** Forgets everything bound to a slot a collection freed. */
287
+ function releaseSlot(state: DocumentState, local: number): void {
288
+ uncacheView(state, local);
289
+ const entry = state.data.get(local);
290
+ if (entry) { entry.isReleased = true; state.data.delete(local); }
291
+ releaseAdapterNode(state, local);
292
+ }
293
+
294
+ /** Hands a freed slot's tree adapter object back to the caller: passed in again, it is imported like any other node. */
295
+ function releaseAdapterNode(state: DocumentState, local: number): void {
296
+ const adapterNode = state.adapterNodes?.[local] as Record<symbol, unknown> | undefined;
297
+ if (!adapterNode) return;
298
+ adapterNode[VIEW_SELF] = undefined;
299
+ state.adapterNodes![local] = undefined;
300
+ }
301
+
302
+ function hasViews(state: DocumentState): boolean {
303
+ return state.views.length > 0 || state.looseViews.size > 0 || state.weakViews.size > 0;
304
+ }
305
+
306
+ function forEachView(state: DocumentState, visit: (view: object, local: number) => void): void {
307
+ state.views.forEach((view, local) => { if (view) visit(view, local); });
308
+ for (const [local, view] of state.looseViews) visit(view, local);
309
+ for (const [local, reference] of state.weakViews) { const view = reference.deref(); if (view) visit(view, local); }
310
+ }
311
+
312
+ /**
313
+ * Node objects of detached nodes stay cached only while something else holds
314
+ * them, so a later collection can reclaim their nodes. The `WeakRef`s are
315
+ * taken during a collection rather than on removal: taking one keeps its
316
+ * target, and so its document, alive until the current job ends. Returns
317
+ * whether any node object was weakened.
318
+ */
319
+ function weakenDetachedViews(state: DocumentState): boolean {
320
+ if (!state.raw.detachedSlots) return false;
321
+ let isWeakened = false;
322
+ for (const local of state.raw.detachedSlots(state.rootLocal ??= state.raw.rootId())) {
323
+ const view = local < state.baseline ? state.views[local] : state.looseViews.get(local);
324
+ if (!view) continue;
325
+ if (local < state.baseline) state.views[local] = undefined;
326
+ else state.looseViews.delete(local);
327
+ state.weakViews.set(local, new WeakRef(view));
328
+ isWeakened = true;
329
+ }
330
+ return isWeakened;
331
+ }
332
+
333
+ /** The state owning `id` of a document whose ids have reached other documents. */
334
+ function exposedStateOf(id: number): DocumentState {
335
+ const state = registry.states[Math.floor(id / STRIDE)]?.deref();
336
+ if (!state || !(id >= 0)) throw new RangeError(`Unknown native node handle ${id}`);
337
+ return state;
338
+ }
339
+
340
+ /** The state owning `id`, resolved without the weak table when `document` owns it. */
341
+ export function stateIn(document: NativeDocument, id: number): DocumentState {
342
+ const own = (document as Partial<RoutedDocument>).state;
343
+ if (own === undefined || id < own.base || id >= own.base + STRIDE) return exposedStateOf(id);
344
+ if (own.isCollectionEvicted) resumeCollection(document);
345
+ return own;
346
+ }
347
+
348
+ /** Schedules again a collection that left the pending list before it ran. */
349
+ export function resumeCollection(document: NativeDocument): void {
350
+ const state = (document as Partial<RoutedDocument>).state;
351
+ if (!state?.isCollectionEvicted) return;
352
+ state.isCollectionEvicted = false;
353
+ scheduleCollection(state);
354
+ }
355
+
356
+ /** The node's record for the current generation, with ids local to `state`. */
357
+ export function recordAt(state: DocumentState, local: number): NativeNodeRecord | null {
358
+ const cached = state.records[local];
359
+ if (cached !== undefined) return cached;
360
+ const record = readRecord(state, local);
361
+ state.records[local] = record;
362
+ return record;
363
+ }
364
+
365
+ const minimumReadsBeforeSnapshot = 64;
366
+
367
+ /**
368
+ * Once individual reads in a generation cost about as much as copying every
369
+ * record, take one snapshot and decode the rest locally. A snapshot costs
370
+ * about two reads plus one read per hundred nodes, and a document read once
371
+ * is usually read again, so an unchanged document of under a hundred nodes
372
+ * takes one on its first read. Mutations start a new generation.
373
+ */
374
+ function readRecord(state: DocumentState, local: number): NativeNodeRecord | null {
375
+ const bulk = state.bulk;
376
+ if (bulk instanceof NodeSnapshot) {
377
+ const record = state.stale.size && state.stale.has(local) ? undefined : bulk.record(local);
378
+ if (record !== undefined) return record;
379
+ } else if (state.raw.snapshot) {
380
+ const reads = bulk + 1;
381
+ const size = Math.max(state.snapshotSize, state.baseline);
382
+ // An unchanged small document is read throughout; after a write, often only a few nodes are.
383
+ const firstSnapshotRead = state.generation === 0 ? size / 100 : 1 + size / 100;
384
+ if (reads >= Math.min(minimumReadsBeforeSnapshot, firstSnapshotRead)) {
385
+ const snapshot = new NodeSnapshot(state.raw.snapshot());
386
+ state.snapshotSize = snapshot.size;
387
+ state.bulk = snapshot;
388
+ const record = snapshot.record(local);
389
+ if (record !== undefined) return record;
390
+ } else state.bulk = reads;
391
+ }
392
+ return rawOperation(state.raw, 'node', { id: local });
393
+ }
394
+
395
+ /** The current snapshot when it holds `local` exactly, so its fields can be read without a record. */
396
+ export function plainSnapshot(state: DocumentState, local: number): NodeSnapshot | undefined {
397
+ const bulk = state.bulk;
398
+ return bulk instanceof NodeSnapshot && !(state.stale.size && state.stale.has(local)) && bulk.isPlain(local) ? bulk : undefined;
399
+ }
400
+
401
+ /** A string attribute of an element, read like the native `attr` lookup. */
402
+ export function attributeAt(state: DocumentState, local: number, name: string): string | undefined {
403
+ const snapshot = plainSnapshot(state, local);
404
+ if (snapshot) return snapshot.attributeValue(local, name);
405
+ const attributes = recordAt(state, local)?.attribs as Record<string, unknown> | undefined;
406
+ const value = attributes && Object.hasOwn(attributes, name) ? attributes[name] : undefined;
407
+ return typeof value === 'string' ? value : undefined;
408
+ }
409
+
410
+ /** Starts a new generation: cached records may no longer match the arena. */
411
+ export function invalidate(state: DocumentState): void {
412
+ state.generation++;
413
+ state.records = [];
414
+ state.bulk = 0;
415
+ state.stale.clear();
416
+ }
417
+
418
+ /** Discards the records of nodes a write changed without touching the tree's shape. */
419
+ function invalidateNodes(state: DocumentState, locals: number[]): void {
420
+ const isSnapshot = state.bulk instanceof NodeSnapshot;
421
+ for (const local of locals) {
422
+ state.records[local] = undefined;
423
+ if (isSnapshot) state.stale.add(local);
424
+ }
425
+ }
426
+
427
+ /** Keeps the ids of `document` that `owner` holds alive, and rewrites them when their nodes move. */
428
+ export function leaseNodes(document: NativeDocument, owner: object, ids: number[]): Lease {
429
+ const token: LeaseToken = { isReleased: false };
430
+ const states = statesOf(document, ids);
431
+ const lease: Lease = { ids, token, states };
432
+ for (const state of states) addLease(state, lease);
433
+ registry.leaseFinalizer.register(owner, token);
434
+ return lease;
435
+ }
436
+
437
+ /** Points a lease at a replacement id list. */
438
+ export function updateLease(document: NativeDocument, lease: Lease, ids: number[]): void {
439
+ lease.ids = ids;
440
+ lease.states = statesOf(document, ids);
441
+ for (const state of lease.states) addLease(state, lease);
442
+ }
443
+
444
+ function addLease(state: DocumentState, lease: Lease): void {
445
+ state.leases.add(lease);
446
+ if (state.leases.size < state.leaseSweepSize) return;
447
+ for (const held of state.leases) if (held.token.isReleased) state.leases.delete(held);
448
+ state.leaseSweepSize = state.leases.size * 2 + 64;
449
+ }
450
+
451
+ /** Weakened node objects become unreachable only after a later garbage collection. */
452
+ const collectionRetries = 3;
453
+
454
+ /** Reclaims removed nodes that no JavaScript owner references. */
455
+ export function collectState(state: DocumentState): NativeMemoryStats | undefined {
456
+ registry.pending.delete(state);
457
+ state.isCollectionEvicted = false;
458
+ if (!state.raw.collectPinned) return state.raw.collectGarbage?.();
459
+ if (weakenDetachedViews(state)) state.collectionRetries = collectionRetries;
460
+ const pins: number[] = [];
461
+ const { base } = state;
462
+ const end = base + STRIDE;
463
+ for (const lease of state.leases) {
464
+ if (lease.token.isReleased) { state.leases.delete(lease); continue; }
465
+ for (const id of lease.ids) if (id >= base && id < end) pins.push(id - base);
466
+ }
467
+ state.views.forEach((view, local) => { if (view) pins.push(local); });
468
+ for (const local of state.looseViews.keys()) pins.push(local);
469
+ for (const [local, reference] of state.weakViews) {
470
+ if (reference.deref()) pins.push(local);
471
+ else state.weakViews.delete(local);
472
+ }
473
+ const { free, ...stats } = state.raw.collectPinned(Uint32Array.from(pins), rootSlots);
474
+ invalidate(state);
475
+ for (const local of free) releaseSlot(state, local);
476
+ releaseSlotsFrom(state, stats.slots);
477
+ state.looseViewLimit = Math.max(minimumLooseViewLimit, state.looseViews.size * 2);
478
+ if (state.collectionRetries > 0 && state.weakViews.size > 0) {
479
+ state.collectionRetries--;
480
+ scheduleCollection(state);
481
+ }
482
+ return stats;
483
+ }
484
+
54
485
  function rawOperation<T = any>(document: NativeDocument, command: string, args: any): T {
55
486
  const result = document.op(command, JSON.stringify(args));
56
487
  return result === '' || result === undefined ? undefined as T : JSON.parse(result);
57
488
  }
58
- function localMap(document: NativeDocument): Map<number, HandleEntry> {
59
- let map = registry.byDocument.get(document);
60
- if (!map) registry.byDocument.set(document, map = new Map());
61
- return map;
62
- }
63
- function handleFor(document: ManagedNativeDocument, local: number): Handle {
64
- const map = localMap(document);
65
- let handle = map.get(local)?.reference.deref();
66
- if (!handle || handle.document !== document || handle.local !== local) {
67
- const pin: PinRecord = { global: registry.next++, document: new WeakRef(document), local };
68
- handle = { global: pin.global, document, local, pin };
69
- document.retain?.([local]);
70
- const reference = new WeakRef(handle);
71
- map.set(local, { reference, pin }); registry.handles.set(handle.global, reference);
72
- registry.finalizer.register(handle, pin);
73
- }
74
- return handle;
75
- }
76
489
 
77
- /** Keep numeric native IDs alive for precisely the lifetime of their JS owner. */
78
- export function retainNodes(document: NativeDocument, ids: number[]): object {
79
- return (document as NativeDocument & { retainNodes?(ids: number[]): object }).retainNodes?.(ids) ?? {};
490
+ /** Converts a record's local links to ids. */
491
+ function globalRecord(state: DocumentState, record: NativeNodeRecord | null): NativeNodeRecord | null {
492
+ if (!record) return null;
493
+ const { base } = state;
494
+ const link = (local: number | null | undefined): number | null => local == null ? null : base + local;
495
+ return { ...plainRecord(record), id: base + record.id, children: (record.children ?? []).map((child) => base + child), parent: link(record.parent), prev: link(record.prev), next: link(record.next) } as NativeNodeRecord;
80
496
  }
81
497
 
498
+ const parentTypes = new Set(['root', 'tag', 'script', 'style', 'cdata']);
499
+ const pointerFields = new Set(['parent', 'parentNode', 'prev', 'previousSibling', 'next', 'nextSibling']);
500
+ const idResultCommands = new Set(['select', 'matches', 'uniqueSort', 'traverse', 'clone']);
501
+ const scratchCommands = new Set(['select', 'matches', 'uniqueSort', 'serializeArray', 'serialize', 'extractPlan']);
502
+ const writeCommands = new Set(['create', 'createNode', 'setNode', 'import', 'insert', 'clone', 'remove', 'removeAttr', 'removeData', 'addClass', 'removeClass', 'toggleClass']);
503
+ const attributeFields = new Set(['attribs', 'x-attribsNamespace', 'x-attribsPrefix']);
504
+ const targetedAttributeWrites = new Set(['attr', 'removeAttr', 'addClass', 'removeClass', 'toggleClass', 'css']);
505
+ /** Writes that only change the records of their own ids. */
506
+ const recordOnlyWrites = new Set([...targetedAttributeWrites, 'data', 'removeData']);
507
+ const structuralWrites = new Set(['create', 'createNode', 'import', 'insert', 'clone', 'remove', 'html', 'text', 'data', 'removeData']);
508
+
82
509
  /**
83
- * Maps stable JS handles to their current native arena. Native adoption moves
84
- * the DOM; this bridge only translates IDs and routes commands to its owner.
85
- * Global tables contain weak references. Live APIs and node handles retain the
86
- * ownership records they use, including records moved to another arena.
510
+ * Routes commands for ids from any document to the arena that owns them.
511
+ * Native adoption moves nodes between arenas; `moveInto` rewrites the ids that
512
+ * live owners hold so node identities survive the move.
87
513
  */
88
514
  export class RoutedDocument implements NativeDocument {
89
515
  readonly nodeSpace = registry.space;
90
516
  readonly options: CheerioOptions;
91
- private readonly anchor: Handle;
92
- get raw(): ManagedNativeDocument { return this.anchor.document; }
517
+ readonly state: DocumentState;
518
+ get raw(): ManagedNativeDocument { return this.state.raw; }
93
519
 
94
- constructor(document: NativeDocument, options: CheerioOptions = {}, anchor?: Handle) {
520
+ constructor(document: NativeDocument, options: CheerioOptions = {}) {
95
521
  this.options = options;
96
- this.anchor = anchor ?? handleFor(document, document.rootId());
522
+ this.state = stateFor(document, options);
97
523
  }
98
- private keep(handle: Handle): number { return handle.global; }
99
- private lookup(id: number): Handle {
100
- const handle = registry.handles.get(id)?.deref();
101
- if (!handle) throw new RangeError(`Unknown native node handle ${id}`);
102
- return handle;
524
+
525
+ /**
526
+ * Sets (or with `null` removes) an attribute, as the `attr` command does,
527
+ * through a typed native call. Attribute objects already handed out are
528
+ * updated in place instead of being read back.
529
+ */
530
+ setAttribute(ids: number[], name: string, value: string | null): void {
531
+ registry.writes++;
532
+ for (const { state, locals } of this.groups(ids)) {
533
+ if (!state.raw.setAttribute) { this.call(state, 'attr', { ids: locals, name, value }); continue; }
534
+ invalidateNodes(state, locals);
535
+ state.raw.setAttribute(locals, name, value);
536
+ for (const local of locals) {
537
+ const target = ((cachedView(state, local) as Record<symbol, { target?: Record<string, string> } | undefined> | undefined)?.[VIEW_ATTRIBUTES])?.target;
538
+ if (!target) continue;
539
+ if (value === null) delete target[name];
540
+ else if (name === '__proto__') Object.defineProperty(target, name, { value, writable: true, enumerable: true, configurable: true });
541
+ else target[name] = value;
542
+ }
543
+ }
103
544
  }
104
- private global(document: NativeDocument, local: number): number { return this.keep(handleFor(document, local)); }
105
- private globals(document: NativeDocument, ids: number[]): number[] { return ids.map((id) => this.global(document, id)); }
106
- retainNode(id: number): void { this.lookup(id); }
107
- retainNodes(ids: number[]): object { return ids.filter((id) => id !== undefined).map((id) => this.lookup(id)); }
108
- nodeDocument(id: number): NativeDocument { return new RoutedDocument(this.raw, this.options, this.lookup(id)); }
109
- collectGarbage(): NativeMemoryStats | undefined { registry.snapshots.delete(this.raw); return this.raw.collectGarbage?.(); }
110
- retainNodeData(id: number, data: object): void { this.lookup(id).data = data; }
111
- nodeData(id: number): object | undefined { return this.lookup(id).data; }
545
+
546
+ retainNode(id: number): void { this.owner(id); }
547
+ collectGarbage(): NativeMemoryStats | undefined { return collectState(this.state); }
548
+ retainNodeData(id: number, data: object): void { dataEntry(this.owner(id), id).data = data; }
549
+ nodeData(id: number): object | undefined { const state = this.owner(id); return state.data.get(id - state.base)?.data; }
112
550
  nodeDataOperation(id: number): (command: string, args: any) => any {
113
- const handle = this.lookup(id);
551
+ const entry = dataEntry(this.owner(id), id);
114
552
  const options = this.options;
115
- return (command, args) => rawOperation(handle.document, command, { ...args, ids: [handle.local], options });
553
+ // Resolve the entry's current arena on each call: the node may have moved.
554
+ return (command, args) => entry.isReleased ? undefined : routedFor(entry.state).operate(command, { ...args, ids: [entry.state.base + entry.local], options });
116
555
  }
117
- localNodeId(id: number): number { return this.global(this.raw, id); }
118
- rootId(): number { return this.global(this.raw, this.raw.rootId()); }
119
-
120
- private record(document: NativeDocument, record: NativeNodeRecord | null): NativeNodeRecord | null {
121
- if (!record) return null;
122
- const view = { ...record, id: this.global(document, record.id) };
123
- // Translate links only when read. A text callback must not allocate/pin
124
- // handles for every unobserved child and sibling in its ancestor records.
125
- Object.defineProperty(view, 'children', { enumerable: true, get: () => this.globals(document, record.children ?? []) });
126
- for (const key of ['parent', 'prev', 'next']) Object.defineProperty(view, key, { enumerable: true, get: () => record[key] == null ? null : this.global(document, record[key]) });
127
- return view;
556
+ localNodeId(local: number): number { return this.state.base + local; }
557
+ rootId(): number { return this.state.base + (this.state.rootLocal ??= this.raw.rootId()); }
558
+ isOwnNode(id: number): boolean { return id >= this.state.base && id < this.state.base + STRIDE; }
559
+ private owner(id: number): DocumentState { return stateIn(this, id); }
560
+
561
+ private globals(state: DocumentState, locals: number[]): number[] {
562
+ const { base } = state;
563
+ return locals.map((local) => base + local);
128
564
  }
129
- private groups(ids: number[]): { document: NativeDocument; ids: number[]; global: number[] }[] {
130
- const groups: { document: NativeDocument; ids: number[]; global: number[] }[] = [];
565
+
566
+ private groups(ids: number[]): { state: DocumentState; locals: number[] }[] {
567
+ const groups: { state: DocumentState; locals: number[] }[] = [];
568
+ const own = this.state;
569
+ let last: { state: DocumentState; locals: number[] } | undefined;
131
570
  for (const id of ids) {
132
- const handle = this.lookup(id);
133
- const last = groups[groups.length - 1];
134
- if (last?.document === handle.document) { last.ids.push(handle.local); last.global.push(id); }
135
- else groups.push({ document: handle.document, ids: [handle.local], global: [id] });
571
+ const state = id >= own.base && id < own.base + STRIDE ? own : exposedStateOf(id);
572
+ if (last?.state === state) last.locals.push(id - state.base);
573
+ else groups.push(last = { state, locals: [id - state.base] });
136
574
  }
137
575
  return groups;
138
576
  }
139
- private call<T = any>(document: NativeDocument, command: string, args: any): T {
140
- if (command === 'extractPlan' && JSON.stringify(args.plan).includes('innerHTML')) registry.snapshots.delete(document);
141
- if (Object.hasOwn(args, 'value') || ['create', 'createNode', 'setNode', 'import', 'insert', 'clone', 'remove', 'removeAttr', 'removeData', 'addClass', 'removeClass', 'toggleClass'].includes(command)) registry.snapshots.delete(document);
142
- return rawOperation(document, command, { ...args, options: args.options ?? this.options });
577
+
578
+ private call<T = any>(state: DocumentState, command: string, args: any): T {
579
+ if (command === 'extractPlan' && JSON.stringify(args.plan).includes('innerHTML')) invalidate(state);
580
+ const isWrite = Object.hasOwn(args, 'value') || writeCommands.has(command);
581
+ if (isWrite) {
582
+ registry.writes++;
583
+ const changed = recordOnlyWrites.has(command) ? args.ids ?? [] : command === 'setNode' && attributeFields.has(args.field) ? [args.id] : undefined;
584
+ if (changed) invalidateNodes(state, changed);
585
+ else invalidate(state);
586
+ }
587
+ const result = rawOperation(state.raw, command, { ...args, options: args.options ?? this.options });
588
+ if (isWrite) { refreshWrittenAttributes(state, command, args); refreshChildLists(state); scheduleCollectionIfDue(state); }
589
+ return result;
143
590
  }
144
591
 
145
- private move(target: NativeDocument, ids: number[], copy = false): number[] {
146
- if (!copy) {
147
- const groups = new Map<NativeDocument, Handle[]>();
148
- for (const id of ids) { const handle = this.lookup(id); const group = groups.get(handle.document) ?? []; group.push(handle); groups.set(handle.document, group); }
149
- for (const [source, handles] of groups) {
150
- if (source === target) continue;
151
- if (!target.adopt) throw new Error('The native addon does not support cross-document adoption. Rebuild the Rust addon.');
152
- registry.snapshots.delete(source); registry.snapshots.delete(target);
153
- const adopted: { mapping: [number, number][] } = JSON.parse(target.adopt(source, handles.map((handle) => handle.local), true));
154
- for (const [oldLocal, newLocal] of adopted.mapping) {
155
- const entry = localMap(source).get(oldLocal);
156
- if (!entry) continue;
157
- const handle = entry.reference.deref();
158
- if (handle && (handle.document !== source || handle.local !== oldLocal)) continue;
159
- localMap(source).delete(oldLocal);
160
- entry.pin.document = new WeakRef(target); entry.pin.local = newLocal;
161
- if (handle) { handle.document = target; handle.local = newLocal; }
162
- localMap(target).set(newLocal, entry);
163
- }
592
+ /** Moves or copies nodes into `target`; returns their local ids there. */
593
+ private moveInto(target: DocumentState, ids: number[], isCopy = false): number[] {
594
+ if (!isCopy) {
595
+ for (const group of this.groups(ids)) {
596
+ if (group.state === target) continue;
597
+ if (!target.raw.adopt) throw new Error('The native addon does not support cross-document adoption. Rebuild the Rust addon.');
598
+ const adopted: { mapping: [number, number][] } = JSON.parse(target.raw.adopt(group.state.raw, group.locals, true));
599
+ rehome(group.state, target, adopted.mapping, ids);
600
+ refreshChildLists(group.state);
601
+ refreshChildLists(target);
602
+ scheduleCollectionIfDue(group.state);
164
603
  }
165
- return ids.map((id) => this.lookup(id).local);
604
+ return ids.map((id) => id - target.base);
166
605
  }
167
606
  const result: number[] = [];
168
607
  for (const group of this.groups(ids)) {
169
- if (copy && group.document === target) {
170
- result.push(...this.call<number[]>(target, 'clone', { ids: group.ids, deep: true }));
608
+ if (group.state === target) {
609
+ result.push(...this.call<number[]>(target, 'clone', { ids: group.locals, deep: true }));
171
610
  continue;
172
611
  }
173
- if (!target.adopt) throw new Error('The native addon does not support cross-document adoption. Rebuild the Rust addon.');
174
- registry.snapshots.delete(target);
175
- const adopted: { roots: number[]; mapping: [number, number][] } = JSON.parse(target.adopt(group.document, group.ids, !copy));
612
+ if (!target.raw.adopt) throw new Error('The native addon does not support cross-document adoption. Rebuild the Rust addon.');
613
+ invalidate(target);
614
+ const adopted: { roots: number[] } = JSON.parse(target.raw.adopt(group.state.raw, group.locals, false));
176
615
  result.push(...adopted.roots);
177
616
  }
178
617
  return result;
179
618
  }
180
619
 
181
620
  private scratch(command: string, args: any, ids: number[]): any {
182
- const documents = [...new Set(ids.map((id) => this.lookup(id).document))];
621
+ const states = [...new Set(ids.map((id) => this.owner(id)))];
183
622
  const scratch = new (requireNative().NativeDocument)('', false, JSON.stringify(args.options ?? this.options));
184
623
  const toScratch = new Map<number, number>();
185
624
  const fromScratch = new Map<number, number>();
186
- for (const document of documents) {
187
- const records: NativeNodeRecord[] = JSON.parse(document.exportTree());
625
+ for (const state of states) {
626
+ const records: NativeNodeRecord[] = JSON.parse(state.raw.exportTree());
188
627
  const roots = records.filter((record) => record.parent == null).map((record) => record.id);
189
628
  if (!scratch.adopt) throw new Error('The native addon does not support native document copies.');
190
- const copied: { mapping: [number, number][] } = JSON.parse(scratch.adopt(document, roots, false));
629
+ const copied: { mapping: [number, number][] } = JSON.parse(scratch.adopt(state.raw, roots, false));
191
630
  for (const [local, copy] of copied.mapping) {
192
- const global = this.global(document, local);
631
+ const global = state.base + local;
193
632
  toScratch.set(global, copy); fromScratch.set(copy, global);
194
633
  }
195
634
  }
196
635
  const translated = { ...args };
197
636
  for (const key of ['ids', 'contexts']) if (Array.isArray(args[key])) translated[key] = args[key].map((id: number) => toScratch.get(id)).filter((id: number | undefined) => id !== undefined);
198
637
  if (args.pseudoMatches) translated.pseudoMatches = Object.fromEntries(Object.entries(args.pseudoMatches).map(([key, values]) => [key, (values as number[]).map((id) => toScratch.get(id)).filter((id) => id !== undefined)]));
199
- const result = this.call(scratch, command, translated);
638
+ const result = rawOperation(scratch, command, { ...translated, options: translated.options ?? this.options });
200
639
  return ['select', 'matches', 'uniqueSort'].includes(command) ? (result as number[]).map((id) => fromScratch.get(id)!).filter((id) => id !== undefined) : result;
201
640
  }
202
641
 
642
+ private isMixed(groups: { state: DocumentState }[]): boolean {
643
+ return groups.length > 1 && groups.some((group) => group.state !== groups[0].state);
644
+ }
645
+
203
646
  select(selector: string, contexts?: number[]): number[] {
204
647
  const ids = contexts ?? [this.rootId()];
205
- if (!ids.length) return this.call(this.raw, 'select', { selector, contexts: [] });
648
+ if (!ids.length) return this.call(this.state, 'select', { selector, contexts: [] });
206
649
  const groups = this.groups(ids);
207
- if (new Set(groups.map((group) => group.document)).size > 1) return this.scratch('select', { selector, contexts: ids }, ids);
650
+ if (this.isMixed(groups)) return this.scratch('select', { selector, contexts: ids }, ids);
208
651
  const group = groups[0];
209
- const found = group.document === this.raw ? group.document.select(selector, group.ids) : this.call<number[]>(group.document, 'select', { selector, contexts: group.ids });
210
- return this.globals(group.document, found);
652
+ const found = group.state === this.state ? group.state.raw.select(selector, group.locals) : this.call<number[]>(group.state, 'select', { selector, contexts: group.locals });
653
+ return this.globals(group.state, found);
211
654
  }
212
- text(ids: number[]): string { return this.groups(ids).map((group) => group.document.text(group.ids)).join(''); }
213
- innerHtml(id: number): string { const handle = this.lookup(id); registry.snapshots.delete(handle.document); return handle.document === this.raw ? handle.document.innerHtml(handle.local) : this.call(handle.document, 'html', { ids: [handle.local] }); }
214
- outerHtml(ids: number[]): string { return this.groups(ids).map((group) => group.document === this.raw ? group.document.outerHtml(group.ids) : this.call<string>(group.document, 'render', { ids: group.ids })).join(''); }
215
- attr(id: number, name: string): string | undefined { const handle = this.lookup(id); return this.call(handle.document, 'attr', { ids: [handle.local], name }) ?? undefined; }
216
- tagName(id: number): string | undefined { const handle = this.lookup(id); return rawOperation<NativeNodeRecord | null>(handle.document, 'node', { id: handle.local })?.name; }
217
- exportTree(): string { return JSON.stringify((JSON.parse(this.raw.exportTree()) as NativeNodeRecord[]).map((record) => this.record(this.raw, record))); }
218
- create(html: string, contextId?: number): number[] { return JSON.parse(this.op('create', JSON.stringify({ html, contextId }))); }
655
+ text(ids: number[]): string { return this.groups(ids).map((group) => group.state.raw.text(group.locals)).join(''); }
656
+ innerHtml(id: number): string {
657
+ const state = this.owner(id);
658
+ const local = id - state.base;
659
+ // Serializing template content splices its fragment into the element.
660
+ const isSplicing = recordAt(state, local)?.children?.some((child) => recordAt(state, child)?.type === 'root') ?? false;
661
+ if (isSplicing) invalidate(state);
662
+ const html = state === this.state ? state.raw.innerHtml(local) : this.call<string>(state, 'html', { ids: [local] });
663
+ if (isSplicing && state.hasChildLists && childRefresher) forEachView(state, childRefresher);
664
+ return html;
665
+ }
666
+ outerHtml(ids: number[]): string { return this.groups(ids).map((group) => group.state === this.state ? group.state.raw.outerHtml(group.locals) : this.call<string>(group.state, 'render', { ids: group.locals })).join(''); }
667
+ attr(id: number, name: string): string | undefined { const state = this.owner(id); return this.call(state, 'attr', { ids: [id - state.base], name }) ?? undefined; }
668
+ tagName(id: number): string | undefined { const state = this.owner(id); return recordAt(state, id - state.base)?.name; }
669
+ exportTree(): string { return JSON.stringify((JSON.parse(this.raw.exportTree()) as NativeNodeRecord[]).map((record) => globalRecord(this.state, record))); }
670
+ create(html: string, contextId?: number): number[] { return this.operate('create', { html, contextId }); }
219
671
 
220
672
  op(command: string, json: string): string { return JSON.stringify(this.operate(command, JSON.parse(json)) ?? null); }
221
673
 
222
674
  operate(command: string, args: any): any {
223
675
  const options = args.options ?? this.options;
224
- let result: any;
225
- if (command === 'node') {
226
- const handle = this.lookup(args.id);
227
- let snapshots = registry.snapshots.get(handle.document);
228
- if (!snapshots) registry.snapshots.set(handle.document, snapshots = new Map());
229
- if (!snapshots.has(handle.local)) snapshots.set(handle.local, rawOperation(handle.document, 'node', { id: handle.local }));
230
- result = this.record(handle.document, snapshots.get(handle.local)!);
231
- } else if (command === 'createNode') result = this.global(this.raw, this.call<number>(this.raw, command, args));
232
- else if (command === 'create') {
233
- const context = args.contextId == null ? undefined : this.lookup(args.contextId);
234
- const document = context?.document ?? this.raw;
676
+ const hasValue = Object.hasOwn(args, 'value');
677
+ if (command === 'node') { const state = this.owner(args.id); return globalRecord(state, recordAt(state, args.id - state.base)); }
678
+ if (command === 'createNode') return this.state.base + this.call<number>(this.state, command, args);
679
+ if (command === 'import') return this.globals(this.state, this.call(this.state, command, args));
680
+ if (command === 'create') {
681
+ const context = args.contextId == null ? undefined : this.owner(args.contextId);
682
+ const state = context ?? this.state;
683
+ const contextLocal = context ? args.contextId - context.base : undefined;
235
684
  if (hasParserHooks(options)) {
236
- const contextRecord = context ? rawOperation<NativeNodeRecord>(context.document, 'node', { id: context.local }) : undefined;
237
- const parsed = parseNativeDocument(args.html, { ...options, ...(contextRecord ? { __contextName: contextRecord.name, __contextNamespace: contextRecord.namespace } : {}) }, false);
238
- const children = rawOperation<NativeNodeRecord>(parsed, 'node', { id: parsed.rootId() }).children;
239
- this.move(document, children);
240
- result = children;
241
- } else result = this.globals(document, this.call(document, command, { ...args, contextId: context?.local }));
242
- } else if (command === 'import') result = this.globals(this.raw, this.call(this.raw, command, args));
243
- else if (command === 'setNode') {
244
- let handle = this.lookup(args.id);
245
- let value = args.value;
246
- if (args.field === 'children') value = this.move(handle.document, value ?? []);
247
- else if (['parent', 'parentNode', 'prev', 'previousSibling', 'next', 'nextSibling'].includes(args.field) && value != null) {
248
- const pointer = this.lookup(value);
249
- if (pointer.document !== handle.document) { this.move(pointer.document, [args.id]); handle = this.lookup(args.id); }
250
- value = this.lookup(args.value).local;
685
+ const contextRecord = context ? recordAt(context, contextLocal!) : undefined;
686
+ const children = parsedChildren(args.html, { ...options, ...(contextRecord ? { __contextName: contextRecord.name, __contextNamespace: contextRecord.namespace } : {}) });
687
+ return this.globals(state, this.moveInto(state, children));
688
+ }
689
+ return this.globals(state, this.call(state, command, { ...args, contextId: contextLocal }));
690
+ }
691
+ if (command === 'setNode' || command === 'prop' && hasValue && (args.name === 'children' || args.name === 'childNodes' || pointerFields.has(args.name))) {
692
+ const field = command === 'setNode' ? args.field : args.name;
693
+ let result = null;
694
+ for (const id of command === 'setNode' ? [args.id] : args.ids ?? []) {
695
+ let state = this.owner(id);
696
+ let nodeId = id;
697
+ let value = args.value;
698
+ if (field === 'children' || field === 'childNodes') value = this.moveInto(state, value ?? []);
699
+ else if (pointerFields.has(field) && value != null) {
700
+ const pointer = this.owner(value);
701
+ if (pointer !== state) { const moved = [id]; this.moveInto(pointer, moved); nodeId = moved[0]; state = pointer; }
702
+ value = args.value - pointer.base;
703
+ }
704
+ const local = nodeId - state.base;
705
+ result = this.call(state, command, command === 'setNode' ? { ...args, id: local, value } : { ...args, ids: [local], value });
251
706
  }
252
- result = this.call(handle.document, command, { ...args, id: handle.local, value });
253
- } else if (command === 'insert') {
707
+ return result;
708
+ }
709
+ if (command === 'insert') {
254
710
  const targets: number[] = args.targetIds ?? [];
255
711
  const nodes: number[] = args.nodeIds ?? [];
256
- result = [];
712
+ const sameDocument = targets.length > 0 && this.groups([...targets, ...nodes]);
713
+ if (sameDocument && sameDocument.length === 1) {
714
+ // One native call clones for every target but the last, as the loop below does per target.
715
+ const { state } = sameDocument[0];
716
+ const inserted = this.call<number[]>(state, command, { ...args, targetIds: targets.map((id) => id - state.base), nodeIds: nodes.map((id) => id - state.base) });
717
+ return this.globals(state, inserted);
718
+ }
719
+ const result: number[] = [];
257
720
  for (let index = 0; index < targets.length; index++) {
258
- const target = this.lookup(targets[index]);
259
- const local = this.move(target.document, nodes, index + 1 < targets.length);
260
- const inserted = this.call<number[]>(target.document, command, { ...args, targetIds: [target.local], nodeIds: local });
261
- result.push(...this.globals(target.document, inserted));
721
+ const target = this.owner(targets[index]);
722
+ const targetLocal = targets[index] - target.base;
723
+ const local = this.moveInto(target, nodes, index + 1 < targets.length);
724
+ const inserted = this.call<number[]>(target, command, { ...args, targetIds: [targetLocal], nodeIds: local });
725
+ result.push(...this.globals(target, inserted));
262
726
  }
263
- } else if (command === 'html' && Object.hasOwn(args, 'value') && hasParserHooks(options)) {
264
- result = null;
727
+ return result;
728
+ }
729
+ if (command === 'html' && hasValue && hasParserHooks(options)) {
730
+ let result = null;
265
731
  for (const id of args.ids ?? []) {
266
- const target = this.lookup(id);
267
- const record = rawOperation<NativeNodeRecord>(target.document, 'node', { id: target.local });
268
- if (!['root', 'tag', 'script', 'style', 'cdata'].includes(record.type)) continue;
269
- const parsed = parseNativeDocument(String(args.value), { ...options, __contextName: record.name, __contextNamespace: record.namespace }, false);
270
- const children = rawOperation<NativeNodeRecord>(parsed, 'node', { id: parsed.rootId() }).children;
271
- const local = this.move(target.document, children);
272
- result = this.call(target.document, 'setNode', { id: target.local, field: 'children', value: local, options });
732
+ const state = this.owner(id);
733
+ const local = id - state.base;
734
+ const record = recordAt(state, local);
735
+ if (!record || !parentTypes.has(record.type)) continue;
736
+ const children = parsedChildren(String(args.value), { ...options, __contextName: record.name, __contextNamespace: record.namespace });
737
+ result = this.call(state, 'setNode', { id: local, field: 'children', value: this.moveInto(state, children), options });
273
738
  }
274
- } else if (command === 'contains') {
275
- const parent = this.lookup(args.container ?? args.parent);
276
- const child = this.lookup(args.contained ?? args.child);
277
- result = parent.document === child.document && this.call(parent.document, command, { container: parent.local, contained: child.local });
278
- } else if (command === 'prop' && Object.hasOwn(args, 'value') && ['children', 'childNodes', 'parent', 'parentNode', 'prev', 'previousSibling', 'next', 'nextSibling'].includes(args.name)) {
279
- result = null;
280
- for (const id of args.ids ?? []) {
281
- let handle = this.lookup(id);
282
- let value = args.value;
283
- if (args.name === 'children' || args.name === 'childNodes') value = this.move(handle.document, value ?? []);
284
- else if (value != null) {
285
- const pointer = this.lookup(value);
286
- if (pointer.document !== handle.document) { this.move(pointer.document, [id]); handle = this.lookup(id); }
287
- value = this.lookup(args.value).local;
288
- }
289
- result = this.call(handle.document, command, { ...args, ids: [handle.local], value });
290
- }
291
- } else {
292
- const ids: number[] = command === 'select' ? args.contexts ?? [this.rootId()] : args.ids ?? [];
293
- const groups = this.groups(ids);
294
- const mixed = new Set(groups.map((group) => group.document)).size > 1;
295
- if (mixed && ['select', 'matches', 'uniqueSort', 'serializeArray', 'serialize', 'extractPlan'].includes(command)) result = this.scratch(command, { ...args, options }, ids);
296
- else if (command === 'extractPlan') {
297
- const group = groups[0];
298
- result = this.call(group?.document ?? this.raw, command, { ...args, options, ids: group?.ids ?? [] });
299
- }
300
- else if (['select', 'matches', 'uniqueSort', 'traverse', 'clone'].includes(command)) {
301
- result = [];
302
- if (!groups.length && (command === 'select' || command === 'matches')) result = this.call(this.raw, command, { ...args, options, ids: [], ...(command === 'select' ? { contexts: [] } : {}) });
303
- for (const group of groups) {
304
- const values = this.call<number[]>(group.document, command, { ...args, options, ids: group.ids, ...(command === 'select' ? { contexts: group.ids } : {}), ...(args.pseudoMatches ? { pseudoMatches: Object.fromEntries(Object.entries(args.pseudoMatches).map(([key, values]) => [key, (values as number[]).map((id) => this.lookup(id)).filter((handle) => handle.document === group.document).map((handle) => handle.local)])) } : {}) });
305
- result.push(...this.globals(group.document, values));
306
- }
307
- } else if (['render', 'outerHtml', 'text'].includes(command) && !Object.hasOwn(args, 'value')) result = groups.map((group) => this.call<string>(group.document, command, { ...args, options, ids: group.ids })).join('');
308
- else if (command === 'hasClass') result = groups.some((group) => this.call<boolean>(group.document, command, { ...args, options, ids: group.ids }));
309
- else if (['serializeArray', 'serialize'].includes(command)) result = groups.length ? this.call(groups[0].document, command, { ...args, options, ids: groups[0].ids }) : command === 'serializeArray' ? [] : '';
310
- else {
311
- if (command === 'html') for (const group of groups) registry.snapshots.delete(group.document);
312
- const writes = Object.hasOwn(args, 'value') || ['remove', 'removeAttr', 'removeData', 'addClass', 'removeClass', 'toggleClass'].includes(command);
313
- const active = writes ? groups : groups.slice(0, 1);
314
- result = null;
315
- for (const group of active) result = this.call(group.document, command, { ...args, options, ids: group.ids });
739
+ return result;
740
+ }
741
+ if (command === 'contains') {
742
+ const container = args.container ?? args.parent;
743
+ const contained = args.contained ?? args.child;
744
+ const parent = this.owner(container);
745
+ const isSameDocument = contained >= parent.base && contained < parent.base + STRIDE;
746
+ return isSameDocument && this.call(parent, command, { container: container - parent.base, contained: contained - parent.base });
747
+ }
748
+ const ids: number[] = command === 'select' ? args.contexts ?? [this.rootId()] : args.ids ?? [];
749
+ const groups = this.groups(ids);
750
+ if (this.isMixed(groups) && scratchCommands.has(command)) return this.scratch(command, { ...args, options }, ids) ?? null;
751
+ if (command === 'extractPlan') { const group = groups[0]; return this.call(group?.state ?? this.state, command, { ...args, options, ids: group?.locals ?? [] }) ?? null; }
752
+ if (idResultCommands.has(command)) {
753
+ const isSelect = command === 'select';
754
+ if (!groups.length && (isSelect || command === 'matches')) return this.call(this.state, command, { ...args, options, ids: [], ...(isSelect ? { contexts: [] } : {}) });
755
+ const result: number[] = [];
756
+ for (const group of groups) {
757
+ const pseudoMatches = args.pseudoMatches ? Object.fromEntries(Object.entries(args.pseudoMatches as Record<string, number[]>).map(([key, values]) => [key, values.filter((id) => id >= group.state.base && id < group.state.base + STRIDE).map((id) => id - group.state.base)])) : undefined;
758
+ const values = this.call<number[]>(group.state, command, { ...args, options, ids: group.locals, ...(isSelect ? { contexts: group.locals } : {}), ...(pseudoMatches ? { pseudoMatches } : {}) });
759
+ result.push(...this.globals(group.state, values));
316
760
  }
761
+ return result;
762
+ }
763
+ if (command === 'render' && !hasValue) {
764
+ const optionsJson = JSON.stringify(options);
765
+ return groups.map((group) => group.state.raw.render ? group.state.raw.render(group.locals, optionsJson) : this.call<string>(group.state, command, { ...args, options, ids: group.locals })).join('');
317
766
  }
767
+ if ((command === 'outerHtml' || command === 'text') && !hasValue) return groups.map((group) => this.call<string>(group.state, command, { ...args, options, ids: group.locals })).join('');
768
+ if (command === 'hasClass') return groups.some((group) => this.call<boolean>(group.state, command, { ...args, options, ids: group.locals }));
769
+ if (command === 'serializeArray' || command === 'serialize') return groups.length ? this.call(groups[0].state, command, { ...args, options, ids: groups[0].locals }) : command === 'serializeArray' ? [] : '';
770
+ if (command === 'html') for (const group of groups) invalidate(group.state);
771
+ const isWrite = hasValue || writeCommands.has(command);
772
+ let result = null;
773
+ for (const group of isWrite ? groups : groups.slice(0, 1)) result = this.call(group.state, command, { ...args, options, ids: group.locals });
318
774
  return result ?? null;
319
775
  }
320
776
  }
321
777
 
778
+ /** Parses `html` into a document of its own, whose children the caller then moves. */
779
+ function parsedChildren(html: string, options: CheerioOptions): number[] {
780
+ const parsed = parseNativeDocument(html, options, false) as RoutedDocument;
781
+ exposeState(parsed.state);
782
+ return (parsed.operate('node', { id: parsed.rootId() }) as NativeNodeRecord).children;
783
+ }
784
+
785
+ function dataEntry(state: DocumentState, id: number): DataEntry {
786
+ const local = id - state.base;
787
+ let entry = state.data.get(local);
788
+ if (!entry) state.data.set(local, entry = { state, local });
789
+ return entry;
790
+ }
791
+
792
+ /**
793
+ * Moves the bookkeeping of adopted nodes to their new arena. Live owners'
794
+ * id lists, node objects and data follow the nodes; `ids` is updated too.
795
+ */
796
+ function rehome(source: DocumentState, target: DocumentState, mapping: [number, number][], ids: number[]): void {
797
+ registry.writes++;
798
+ invalidate(source); invalidate(target);
799
+ // Owners of the moved ids, including selections of the source's API, now hold ids of the target.
800
+ exposeState(target);
801
+ const moved = new Map<number, number>();
802
+ for (const [oldLocal, newLocal] of mapping) {
803
+ moved.set(source.base + oldLocal, target.base + newLocal);
804
+ const node = uncacheView(source, oldLocal) as Record<symbol, unknown> | undefined;
805
+ if (node) {
806
+ node[VIEW_STATE] = target; node[VIEW_LOCAL] = newLocal; cacheView(target, newLocal, node);
807
+ if (node[VIEW_CHILD_TARGET]) target.hasChildLists = true;
808
+ }
809
+ const entry = source.data.get(oldLocal);
810
+ if (entry) { source.data.delete(oldLocal); entry.state = target; entry.local = newLocal; target.data.set(newLocal, entry); }
811
+ const adapterNode = source.adapterNodes?.[oldLocal] as Record<symbol, unknown> | undefined;
812
+ if (adapterNode) {
813
+ source.adapterNodes![oldLocal] = undefined;
814
+ (target.adapterNodes ??= [])[newLocal] = adapterNode;
815
+ adapterNode[VIEW_STATE] = target; adapterNode[VIEW_LOCAL] = newLocal;
816
+ }
817
+ }
818
+ const rewrite = (list: number[]): boolean => {
819
+ let isTouched = false;
820
+ for (let index = 0; index < list.length; index++) {
821
+ const next = moved.get(list[index]);
822
+ if (next !== undefined) { list[index] = next; isTouched = true; }
823
+ }
824
+ return isTouched;
825
+ };
826
+ // Leases first: `ids` may be a lease's own list, which must still hold the
827
+ // moved ids for the lease to be found.
828
+ for (const lease of [...source.leases]) {
829
+ if (!rewrite(lease.ids)) continue;
830
+ target.leases.add(lease);
831
+ if (!lease.states.includes(target)) lease.states.push(target);
832
+ if (!lease.ids.some((id) => id >= source.base && id < source.base + STRIDE)) {
833
+ source.leases.delete(lease);
834
+ lease.states = lease.states.filter((state) => state !== source);
835
+ }
836
+ }
837
+ rewrite(ids);
838
+ }
839
+
840
+ /** Keeps attribute objects that callers hold in step with native writes. */
841
+ function refreshWrittenAttributes(state: DocumentState, command: string, args: any): void {
842
+ if (!hasViews(state)) return;
843
+ let locals: number[] | undefined;
844
+ if (targetedAttributeWrites.has(command)) locals = args.ids ?? [];
845
+ else if (command === 'setNode') { if (!attributeFields.has(args.field)) return; locals = [args.id]; }
846
+ else if (structuralWrites.has(command)) return;
847
+ const refresh = (view: object | undefined): void => { if (view && (view as Record<symbol, unknown>)[VIEW_ATTRIBUTES]) attributeRefresher?.(view); };
848
+ if (locals) for (const local of locals) refresh(cachedView(state, local));
849
+ else forEachView(state, refresh);
850
+ }
851
+
852
+ let attributeRefresher: ((view: object) => void) | undefined;
853
+ let childRefresher: ((view: object) => void) | undefined;
854
+
855
+ /** Registers how node objects re-read their attributes and children after a native write. */
856
+ export function onViewRefresh(attributes: (view: object) => void, children: (view: object) => void): void {
857
+ attributeRefresher = attributes;
858
+ childRefresher = children;
859
+ }
860
+
861
+ /** Refills the child arrays of parents whose children a native write changed. */
862
+ function refreshChildLists(state: DocumentState): void {
863
+ if (!state.hasChildLists || !childRefresher) return;
864
+ const touched = state.raw.takeTouched?.();
865
+ if (touched === undefined) return;
866
+ if (touched === null) { forEachView(state, childRefresher); return; }
867
+ for (const local of touched) { const view = cachedView(state, local); if (view) childRefresher(view); }
868
+ }
869
+
322
870
  export function routeDocument(document: NativeDocument, options: CheerioOptions = {}): NativeDocument {
323
871
  return document.nodeSpace === registry.space ? document : new RoutedDocument(document, options);
324
872
  }
325
873
 
874
+ /** The unanchored routed document node objects of `state` use. */
875
+ export function routedFor(state: DocumentState): RoutedDocument {
876
+ return state.routed ??= new RoutedDocument(state.raw, state.options);
877
+ }
878
+
326
879
  export function sameNodeSpace(first: NativeDocument, second: NativeDocument): boolean {
327
880
  return first === second || !!first.nodeSpace && first.nodeSpace === second.nodeSpace;
328
881
  }
882
+
883
+ /** Whether ids of `state` belong to this copy of the package; nodes of another copy are foreign objects here. */
884
+ export function isLocalState(state: DocumentState): boolean {
885
+ return state.space === registry.space;
886
+ }
887
+
888
+ /** Whether `document` routes ids through this registry. */
889
+ export function isRouted(document: NativeDocument): boolean {
890
+ return document.nodeSpace === registry.space;
891
+ }