jsdom v30.1.0: replacing the tree under the DOM

jsdom stopped storing its document tree in a general-purpose library and wrote one that knows it is holding a DOM, which is why a release whose notes read as a list of small fixes moved 127 files under lib/.

jsdom/jsdom v30.0.1..v30.1.0 at 556b11fc, explained 2026-09-17

Background

New to this area? Expand for the deeper background.

jsdom is a DOM and HTML implementation written in JavaScript. It is what runs when a Node.js test creates a document, queries it, dispatches an event, or reads a computed style, without a browser anywhere.

To do that it has to store a tree, and a DOM tree is asked unusual questions. What are this node's children, but also what is its position among its siblings, what is the nearest ancestor two nodes share, and give me a list of children that stays correct as the tree changes underneath it. Those questions sit on the paths a test exercises most: node.appendChild(), range.cloneContents(), and event dispatch walking from target to root.

Until v30.1.0 jsdom kept the tree in symbol-tree, a general-purpose npm package for holding a tree in objects you do not own. It is a reasonable choice. It is also a library that has no idea it is storing a document, so it cannot assume anything a DOM guarantees.

That last point is where the cost showed up. Four examples, all visible in the v30.0.1 source:

  • Every node paid for tree bookkeeping at construction. Node-impl.js:120 called domSymbolTree.initialize(this) and Node-impl.js:128 allocated this._memoizedQueries = {}, for every node, including Attr nodes that inherit from Node and never join a tree.
  • Asking a node its index walked its siblings, so the answer cost time proportional to how far along it sat.
  • Live child collections rebuilt themselves after every mutation, whether or not anything read them. The commit says only that they rebuilt; that appending N children therefore costs time proportional to N squared is my reading of what rebuilding each time implies, not a figure jsdom publishes.
  • Operations that needed an ancestor chain each walked it themselves. compareDocumentPosition, a range's common ancestor, boundary comparison, and range.surroundContents() repeated the same search, and event dispatch re-walked to the document for every connected parent.
Key concept A live collection is one that reflects later changes to the tree. element.children is live: append a child and the collection you are already holding grows. That is what makes it expensive, because something has to decide when the stored answer stopped being true.
Edge case The change underneath most of this release's performance work has no release-note bullet of its own. jsdom's MAINTAINERS.md:29 style guide says to leave out "commits that have no user-facing impact, e.g. test rolls, refactorings, benchmark additions". Replacing the tree is a refactoring, so the notes record its effects under "Improved performance of DOM construction, tree mutations, range operations, and live collection access" and never name the cause. Reading that omission as the style guide working rather than as an oversight is my inference; no maintainer says so about this release.

Intuition

A general-purpose tree library has to assume the worst. It cannot know that appending a child leaves every existing child's position untouched, because in its world a "tree" is any tree and a caller might do anything. So it recomputes.

A tree that knows it is holding a DOM can assume more. jsdom's replacement keeps a small record next to each node, the Links class at dom-tree.js:13, holding the parent, the siblings, the first and last child, a running childCount, and a cached index. The record is not allocated until a node joins a tree or gains children, so an Attr carries one null field instead of a tree structure it will never use.

The idea that makes the cache work is that mutations differ in how much they disturb. Take a parent with five children:

  • Append a sixth. Children 0 to 4 keep their positions, and the new child takes index 5. Nothing needs recomputing, ever.
  • Insert at position 2. Children 0 and 1 are still right; 2 onward are not. The old library would discard the lot. jsdom truncates the cached list at the insertion point and keeps the valid prefix.

Truncating rather than discarding is what lets the cache survive a mutation at all. Dropping the tail also drops the references it held to the removed subtrees, though whether that was a goal or a consequence the code does not say.

Before, at v30.0.1

node.index()
walk back through siblings
count them
one step per sibling
index

After, at v30.1.0

treeIndex(node)
first or last child?
answer directly
otherwise, cachedIndex
index

The tree below shows the state after inserting a new child at position 2. The prefix that survives is kept; the rest is dropped and rebuilt only if something asks for it.

parent, childCount 6
childindex 0, still valid
childindex 1, still valid
insertedprefix truncated here
childindex recomputed on demand
Edge case A cached index cannot be trusted on its own, because a node that moved can still be carrying a stale one. So the cache is read through an identity check, at dom-tree.js:271-276:
  const cached = parentData.cachedChildren ||= [];
  // A shifted node can retain an old index inside the rebuilt prefix. Check
  // identity as well as the index before using it.
  if (cached[data.cachedIndex] === node) {
    return data.cachedIndex;
  }
Without that comparison the whole scheme would hand back stale positions after any move.

Put together, reading an index now takes one of four paths, and only the last one touches more than a couple of fields.

flowchart TD
    A["treeIndex(node)"] --> B{"is it the first child"}
    B -->|yes| C["return 0"]
    B -->|no| D{"is it the last child"}
    D -->|yes| E["return childCount minus 1"]
    D -->|no| F{"is cachedIndex still trusted"}
    F -->|yes| G["return cachedIndex"]
    F -->|no| H["walk children and fill the prefix array"]
    H --> I["store cachedIndex and return"]
    classDef fast fill:#e4f3ea,stroke:#1a7f47,color:#16181b
    classDef slow fill:#fbefe1,stroke:#b5620a,color:#16181b
    classDef entry fill:#e8eefc,stroke:#3b6cf6,color:#16181b
    class C,E,G fast
    class H,I slow
    class A entry

Code walkthrough

The path: a node is created, joins a tree, the tree indexes it, readers ask for its position or its ancestors, and mutations decide what stays valid. Styles, document lifetime, and the new features hang off that path rather than running down it.

1. Creating a node costs nothing now

The old constructor set up tree bookkeeping and a query cache for every node ever built. The new one sets a null.

lib/jsdom/living/nodes/Node-impl.js:170

-    domSymbolTree.initialize(this);
-    this._memoizedQueries = {};
+    this._links = null;

The commit says why in plain terms: "Attributes inherit from Node but usually don't need tree bookkeeping." The same lazy treatment reached event listeners, which start null at EventTarget-impl.js:24, the on* handler storage, the set of ranges pointing into a node, and the mutation-observer list.

2. Joining a tree, and why append is the easy case

ensureLinks at dom-tree.js:26 allocates the record on first need. Appending then writes one field and increments a counter, with the reasoning stated in a comment rather than left to be worked out.

lib/jsdom/living/helpers/dom-tree.js:299-300

  // Appending preserves every existing child's index.
  childData.cachedIndex = data.childCount++;

3. Reading an index

lib/jsdom/living/helpers/dom-tree.js:256-270

function treeIndex(node) {
  const data = node._links;
  if (data === null || data.parent === null) {
    return -1;
  }
  const parentData = data.parent._links;
  if (data.previousSibling === null) {
    return 0;
  }
  if (data.nextSibling === null) {
    return parentData.childCount - 1;
  }
  if (parentData.cachedChildren === null) {
    return data.cachedIndex;
  }

Three fields carry the state, and the meaning of cachedChildren is a tri-state the code documents at dom-tree.js:20-22: null means append-time indexes are still valid, undefined means they need rebuilding, and an array is a lazily indexed prefix. cachedIndex uses -1 for a node with no parent.

Key concept cachedChildren carries three meanings in one field, which is what the code above is branching on. null means every index was assigned at append time and is still correct. undefined means a mutation invalidated them. An array means jsdom has indexed a prefix of the children and has not looked at the rest. None of the three is a tuning knob: there is no size limit and no threshold anywhere in this file.

4. Mutating, and invalidating only what moved

insertBefore truncates the cached prefix at the mutation point instead of discarding it, in one line at dom-tree.js:323. _invalidateCaches at Node-impl.js:453 replaced a recursive climb up the ancestors with an iterative loop that bumps the version.

Live collections now work the other way round. They used to rebuild on every mutation; now they mark themselves stale and refresh on the next read, which they detect with a counter. Each element carries a _version that only ever increases, and a collection refreshes when its own copy has fallen behind. The commit states the problem directly: "Live childNodes and children collections were rebuilding after each mutation even when nothing read them."

5. Ancestors, walked once

commonAncestorInfo at dom-tree.js:209 returns the shared ancestor together with the two children that lead to each side, and compareTreePosition, a range's common ancestor, boundary comparison, clone, extract, and range.surroundContents() all read that one result. getRootNode at Node-impl.js:356 caches the root with path compression, which the commit ties to a specific symptom: "building an event path would previously repeatedly walk the Document for each connected parent."

6. Paying for an index only when something wants one

lib/jsdom/living/nodes/Node-impl.js:1019

        childIndex ??= childImpl._treeIndex();

That sits inside the loop over the ranges whose boundaries point into this node. A Range holds positions in the tree, so it has to be adjusted whenever the tree beneath it changes, which is why insertion consults them at all. A document that never created one skips the index entirely.

7. Styles: separating what a value is from how it reads

getComputedStyle() had a cache that did the work twice. A hit cloned the stored declaration by resolving and re-parsing every property, which also lowercased case-sensitive background URLs on the way through. _copyDeclarationsFrom at CSSStyleDeclaration-impl.js:49 copies the raw maps instead and leaves resolution lazy.

The larger move separates two things CSS itself keeps apart. A property's computed value is what the cascade and inheritance produce. Its resolved value is what a caller reading the property back should see, which is not always the same. _getComputedPropertyValue at CSSStyleDeclaration-impl.js:231 memoizes the first, while the second became a display-only layer applied in someStyle.getPropertyValue(). That split is what fixed 'border-width': the keywords are thin 1px, medium 3px and thick 5px, and a border styled none or hidden now resolves to 0px. Previously medium went through the font-size path and borderless elements reported 16px.

Two more in the same area. Setting a shorthand used to write the style="" attribute once per longhand it touched; _updateStyle at CSSStyleDeclaration-impl.js:347 defers and writes once, which the commit calls "both a performance and correctness improvement" because the intermediate writes were producing spurious mutation records and custom-element reactions. StyleSheetList-impl.js:28 now inserts sheets in tree order rather than insertion order, so adding a <style> before an existing one no longer puts it last in document.styleSheets and in the cascade.

8. Documents that are closed but still readable

window.close() used to delete the document and strip its DOM, so retained references went dead while queued work kept running against something unreachable. Destruction is now a flag, and the DOM is left intact. _queueATask at Document-impl.js:643 is the new document-scoped queue that discards work after destruction while still settling its promise, so finally cleanup still runs. Timers, frame callbacks, script execution, subresource loads, navigation and someRequest.send() each consult the flag.

complete() at Document-impl.js:614 fixes a separate hang by splitting internal load completion from the public load event, so an <iframe> that removes itself mid-load no longer leaves its parent waiting forever.

9. The three additions

Named access on document went live by uncommenting the IDL getter; the qualifying local names are at Document-impl.js:86 and the lookup at Document-impl.js:717, where a single matching <iframe> returns its someFrame.contentWindow rather than the element. QuotaExceededError is a new exception carrying optional quota and requested values, thrown by web storage and by someCrypto.getRandomValues() past 65536 bytes at Crypto-impl.js:31; the storage limit is 5000000 code units, defaulted at lib/api.js:263. Relaxed name validation at validate-names.js:7 replaces blanket XML productions with the DOM Standard's per-context rules, so document.createElement("a!b") is now accepted where it previously threw.

What this page sets aside

This release changed 417 files. 127 are under lib/, and those are the ones this page is about, though it names only nine of them and treats the rest as instances of the same few changes. The biggest such group is the per-property files under lib/jsdom/living/css/properties/: the border, flex and font-weight resolvers are one idea applied once per property, so reading a second of them teaches nothing the first did not.

Of the remaining 290 files, 240 are under test/web-platform-tests/. One of those, wpt-manifest.json, is generated and accounts for 1,491,149 of the 1,504,358 added lines by itself, which is why a release of this size reports a diff in the millions. Another 25 are benchmarks, which Contributing.md:125 asks contributors to run before and after a change. The last 25 are workflow files, lint configuration, and documentation, including a policy on AI-assisted contribution added in this same range. None of those are explained here.

One set-aside is worth more than a passing mention. Four release-note bullets describe selector fixes, including the someElement.querySelectorAll() regression introduced in v30.0.0. No jsdom selector code changed. jsdom delegates matching to @asamuzakjp/dom-selector through one method at Document-impl.js:274, and the fixes arrived as a version bump from ^8.3.0 at v30.0.1 to ^9.1.2 at v30.1.0. The commit body for the first bump reads, in full, "Fixes #4227." The mechanism lives in the dependency, and this page cannot show it to you.

Edge case The v30.0.0 regression looks like it came from the same place it was fixed. That release took the same dependency from ^7.1.1 to ^8.2.5, a major jump with no per-fix notes. That is my inference from the version history, not something the repository states: no commit, issue or comment in this range names what in 8.x broke context-element matching.

Quiz

jsdom replaced a general-purpose tree library with one written for the DOM. In which situation would the general-purpose library have stayed the better choice?

The caching is licensed by knowing what a DOM guarantees, above all that appending leaves earlier positions untouched. Strip that knowledge away and recomputing is the correct conservative choice, which is what the general-purpose library was doing. The other two describe conditions that argue for the DOM-specific tree, not against it: heavy index lookups are what the cache exists to serve, and unused nodes are what the lazy Links record exists to make free.

Before trusting cachedIndex, the code checks that the child sitting at that position really is this node. What goes wrong if that check is removed?

The cached value travels with the node, so a node shifted within its parent still carries the old number. The identity check is what catches that. Appending is safe without the check because it assigns from childCount, and the detached case is handled earlier by the null-parent test that returns -1.

A document has no live ranges attached to it. Because of that, what does _insert avoid doing?

The index is computed with ??= inside the loop over live ranges, so with no ranges the loop never runs and the index is never needed. The other two happen on every insertion regardless: insertion steps are how elements learn they are connected, and collection invalidation is what keeps a live collection live.

Batching the style="" attribute write is described as a correctness improvement, not only a faster one. Which observable behavior was wrong before?

Setting one shorthand wrote the attribute once per longhand it expanded to, and anything watching the element saw each intermediate write as a real change, including custom-element reactions. Batching makes one write. The other two are genuine defects fixed in this release, but they belong to serialization order and to the computed-versus-resolved split rather than to attribute writes.

The cached prefix is truncated at the point of a mutation rather than thrown away. Which principle does that illustrate?

An insertion at position 2 cannot affect positions 0 and 1, so discarding them throws away work that is still correct. Laziness is also present here, in how the prefix is filled on demand, but it does not explain why the surviving entries are kept. Locality of storage describes where the record lives, not what survives a write.