#4339·jsdom

The CSS parser retains the last stylesheet window through its error callback

Author: domenicCreated Sep 17, 2026Updated Sep 17, 2026
Labelsmemory

Posted by GPT-6 Astra Extra High

Parsing a <style> element can retain the last stylesheet-parsing window, even when the CSS is valid, the window is closed, and the caller drops all strong references. css-tree keeps its last onParseError callback; jsdom's callback captures the window and stylesheet text.

Ten closed windows with a simple valid stylesheet leave window index 9 alive. A diagnostic parse on the same parser without an error callback releases it. A no-stylesheet control retains no windows. This is bounded retention of the last callback's object graph, distinct from the default-stylesheet cache retaining the first style-computation window.

jsdom reproduction and controls

Verified on Node.js v26.8.2, Linux x64, with a clean checkout of jsdom commit 3ab614e5 on 17 September 2026. Its package version is 30.1.0; the commit identifies the tested source more precisely than that version string. No older Node versions were tested.

To reproduce independently, prepare a checkout:

bash
git clone https://github.com/jsdom/jsdom.git jsdom-memory-repro
cd jsdom-memory-repro
git checkout 3ab614e52bc41973937993746bccd203ea874e0e
npm ci
npm run prepare

Save the script below under the indicated filename. Run the commands with the checkout root as the working directory; the scripts deliberately load that checkout's implementation and dependencies. Each command starts a fresh Node process. --expose-gc is required. The event-loop turns before GC matter: neither construction nor WeakRef observations should be assessed only within the job that created the objects.

The pinned dependency is css-tree 3.2.1. Save as css-parser-callback.mjs:

javascript
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { setImmediate } from "node:timers/promises";

const require = createRequire(resolve("package.json"));
const { JSDOM } = require("./lib/api.js");
const windows = [];

function createAndClose() {
  const markup = process.argv[2] === "no-style" ? "<div id=root></div>" :
    "<style>#root { color: orange; }</style><div id=root></div>";
  const { window } = new JSDOM(markup);
  windows.push(new WeakRef(window));
  window.close();
}

async function report(label) {
  for (let i = 0; i < 8; ++i) {
    await setImmediate();
    global.gc();
  }
  console.log({
    label,
    aliveIndices: windows.flatMap((ref, i) => ref.deref() === undefined ? [] : [i])
  });
}

for (let i = 0; i < 10; ++i) {
  createAndClose();
}
await report("after closing ten windows");
// Diagnostic only: overwrite the callback stored by the same parser instance.
require("./lib/jsdom/living/css/helpers/patched-csstree.js").parse("");
await report("after resetting the parser callback");
bash
node --expose-gc css-parser-callback.mjs
node --expose-gc css-parser-callback.mjs no-style

The stylesheet run reports [9] before the diagnostic parser reset and [] afterward. The control reports [] both times. Expected behavior is that all windows can collect without requiring a later parse. The reproduction needs no network, cache map, external script, or application dependency, and never calls window.getComputedStyle().

The extra empty parse is a diagnostic that overwrites the callback; it is not a proposed production workaround or fix.

Dependency-only reproduction and retaining path

This also reproduces in css-tree alone, with no jsdom import. Save as css-tree-callback.mjs and run node --expose-gc css-tree-callback.mjs from the prepared checkout (or a directory containing css-tree 3.2.1):

javascript
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { setImmediate } from "node:timers/promises";

const require = createRequire(resolve("package.json"));
const { parse } = require("css-tree");

function parseWithCallback() {
  const payload = { errors: [] };
  const ref = new WeakRef(payload);
  parse("div { color: orange; }", {
    onParseError(error) {
      payload.errors.push(error);
    }
  });
  return ref;
}

async function report(label, ref) {
  for (let i = 0; i < 8; ++i) {
    await setImmediate();
    global.gc();
  }
  console.log({ label, payloadAlive: ref.deref() !== undefined });
}

const ref = parseWithCallback();
await report("after parsing valid CSS", ref);
parse("");
await report("after parsing without a callback", ref);

Observed payloadAlive is true after parsing valid CSS and false after the second parse. The error callback never needs to run.

A heap snapshot from the earlier jsdom audit at 87979578c786ad4a37dd279a2e4e2fb00a46e926 identified:

Node's module cache
  → patched-csstree.js exported parser
  → parser closure's onParseError
  → jsdom error callback's closure
  → globalObject
  → last stylesheet-parsing Window

The retainer traversal excluded synthetic WeakMap/ephemeron shortcuts before identifying that ordinary strong-reference path.

In jsdom, stylesheets.js creates the callback capturing globalObject and cssText; css-parser.js passes it as onParseError to the shared patched parser. In css-tree 3.2.1, node_modules/css-tree/cjs/parser/create.cjs declares onParseError in createParser()'s closure (line 104), assigns the current callback at the start of parse() (line 350), and leaves it assigned after returning. The equivalent ESM source is lib/parser/create.js.

Next work and regression guidance

Investigate releasing per-call callbacks in css-tree after parsing, including exceptional exits, and consuming that fix in jsdom. If an integration-side fix is preferable, it must still avoid a shared parser holding any closure that captures a window. A weak reference in only one call site may leave other callback paths unresolved. No production fix has been validated yet.

Add a dependency test for callback/payload collection and a jsdom from-outside GC regression that checks collection before any subsequent parse can overwrite the stored callback. Keep the test free of computed-style calls so it does not activate the independent default-stylesheet retainer. Check valid CSS, recoverable parsing errors, a thrown error callback, and imported stylesheet parsing; preserve correct jsdomError reporting to the originating virtual console. Run the CSS parsing API tests and relevant CSSOM WPTs after changing the implementation.

A fix to parser state must consider cleanup on exceptions and the parser's existing reentrancy behavior. Resetting a callback by performing another parse in production has not been evaluated and should not be inferred from the diagnostic above.