Skip to content

Performance regression in jsdom@27 #3985

@Janpot

Description

@Janpot

Node.js version

20.19.1

jsdom version

27.1.0

Minimal reproduction case

// package.json
{
  "name": "jsdom-regression-repro",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node compare.js"
  },
  "dependencies": {
    "jsdom-26": "npm:jsdom@26.0.0",
    "jsdom-27": "npm:jsdom@27.1.0"
  }
}
// compare.js
/**
 * jsdom Performance Regression - Pure DOM Style Operations
 */

async function testJsdomVersion(jsdomPackage, versionLabel) {
  // Dynamically import the jsdom version
  const { JSDOM } = await import(jsdomPackage);

  // Initialize jsdom with empty HTML
  const dom = new JSDOM('<!DOCTYPE html><html><head></head><body></body></html>', {
    url: 'http://localhost',
    pretendToBeVisual: true,
  });
  const document = dom.window.document;

  // Test 1: innerHTML with simple elements (no styles)
  function testInnerHTMLSimple() {
    const container = document.createElement('div');
    document.body.appendChild(container);

    const start = performance.now();
    container.innerHTML = '<div>Hello</div>'.repeat(20);
    const duration = performance.now() - start;

    document.body.removeChild(container);
    return duration;
  }

  // Test 2: innerHTML with inline styles
  function testInnerHTMLStyled() {
    const container = document.createElement('div');
    document.body.appendChild(container);

    const start = performance.now();
    container.innerHTML =
      '<div style="color: blue; background-color: white; padding: 10px; border: 1px solid black;">Cell</div>'.repeat(
        20,
      );
    const duration = performance.now() - start;

    document.body.removeChild(container);
    return duration;
  }

  // Test 3: createElement + style property setting
  function testCreateElementWithStyles() {
    const container = document.createElement('div');
    document.body.appendChild(container);

    const start = performance.now();
    for (let i = 0; i < 20; i++) {
      const div = document.createElement('div');
      div.style.color = 'blue';
      div.style.backgroundColor = 'white';
      div.style.padding = '10px';
      div.style.border = '1px solid black';
      div.textContent = `Cell ${i}`;
      container.appendChild(div);
    }
    const duration = performance.now() - start;

    document.body.removeChild(container);
    return duration;
  }

  // Test 4: createElement + setAttribute('style')
  function testCreateElementWithStyleAttribute() {
    const container = document.createElement('div');
    document.body.appendChild(container);

    const start = performance.now();
    for (let i = 0; i < 20; i++) {
      const div = document.createElement('div');
      div.setAttribute(
        'style',
        'color: blue; background-color: white; padding: 10px; border: 1px solid black;',
      );
      div.textContent = `Cell ${i}`;
      container.appendChild(div);
    }
    const duration = performance.now() - start;

    document.body.removeChild(container);
    return duration;
  }

  // Test 5: createElement + style.cssText
  function testCreateElementWithCssText() {
    const container = document.createElement('div');
    document.body.appendChild(container);

    const start = performance.now();
    for (let i = 0; i < 20; i++) {
      const div = document.createElement('div');
      div.style.cssText =
        'color: blue; background-color: white; padding: 10px; border: 1px solid black;';
      div.textContent = `Cell ${i}`;
      container.appendChild(div);
    }
    const duration = performance.now() - start;

    document.body.removeChild(container);
    return duration;
  }

  // Warmup runs
  for (let i = 0; i < 5; i++) {
    testInnerHTMLSimple();
    testInnerHTMLStyled();
    testCreateElementWithStyles();
    testCreateElementWithStyleAttribute();
    testCreateElementWithCssText();
  }

  // Run tests multiple times
  const iterations = 10;
  const results = {
    innerHTMLSimple: [],
    innerHTMLStyled: [],
    createElementWithStyles: [],
    createElementWithStyleAttribute: [],
    createElementWithCssText: [],
  };

  for (let i = 0; i < iterations; i++) {
    results.innerHTMLSimple.push(testInnerHTMLSimple());
    results.innerHTMLStyled.push(testInnerHTMLStyled());
    results.createElementWithStyles.push(testCreateElementWithStyles());
    results.createElementWithStyleAttribute.push(testCreateElementWithStyleAttribute());
    results.createElementWithCssText.push(testCreateElementWithCssText());
  }

  // Calculate averages
  const avg = {
    innerHTMLSimple: results.innerHTMLSimple.reduce((a, b) => a + b, 0) / iterations,
    innerHTMLStyled: results.innerHTMLStyled.reduce((a, b) => a + b, 0) / iterations,
    createElementWithStyles:
      results.createElementWithStyles.reduce((a, b) => a + b, 0) / iterations,
    createElementWithStyleAttribute:
      results.createElementWithStyleAttribute.reduce((a, b) => a + b, 0) / iterations,
    createElementWithCssText:
      results.createElementWithCssText.reduce((a, b) => a + b, 0) / iterations,
  };

  return {
    versionLabel,
    avg,
  };
}

// Run tests for both versions
console.log('jsdom Performance Regression - Pure DOM Style Operations');
console.log('='.repeat(80));
console.log('Test: Raw DOM operations with inline styles (no React, no libraries)');
console.log('='.repeat(80));
console.log('');

console.log('Testing jsdom 26.0.0...');
const results26 = await testJsdomVersion('jsdom-26', 'jsdom 26.0.0');

console.log('Testing jsdom 27.1.0...');
const results27 = await testJsdomVersion('jsdom-27', 'jsdom 27.1.0');

console.log('');
console.log('='.repeat(80));
console.log('RESULTS');
console.log('='.repeat(80));
console.log('');

function printResults(results) {
  console.log(`${results.versionLabel}:`);
  console.log(
    `  innerHTML (20 simple divs, no styles):     ${results.avg.innerHTMLSimple.toFixed(3)}ms`,
  );
  console.log(
    `  innerHTML (20 divs with inline styles):    ${results.avg.innerHTMLStyled.toFixed(3)}ms`,
  );
  console.log(
    `  createElement + style.property (20 divs):  ${results.avg.createElementWithStyles.toFixed(
      3,
    )}ms`,
  );
  console.log(
    `  createElement + style.cssText (20 divs):   ${results.avg.createElementWithCssText.toFixed(
      3,
    )}ms`,
  );
  console.log(
    `  createElement + setAttribute (20 divs):    ${results.avg.createElementWithStyleAttribute.toFixed(
      3,
    )}ms`,
  );
  console.log('');
}

printResults(results26);
printResults(results27);

// Calculate regressions
console.log('='.repeat(80));
console.log('REGRESSION ANALYSIS');
console.log('='.repeat(80));
console.log('');

function analyzeRegression(label, baseline, regression) {
  const diff = regression - baseline;
  const percent = (diff / baseline) * 100;
  const multiplier = regression / baseline;

  console.log(`${label}:`);
  console.log(`  Difference: ${diff > 0 ? '+' : ''}${diff.toFixed(3)}ms`);
  console.log(`  Change: ${percent > 0 ? '+' : ''}${percent.toFixed(1)}%`);
  console.log(`  Multiplier: ${multiplier.toFixed(2)}x`);
  console.log('');

  return percent;
}

analyzeRegression(
  'innerHTML (simple, no styles)',
  results26.avg.innerHTMLSimple,
  results27.avg.innerHTMLSimple,
);

analyzeRegression(
  'innerHTML (with inline styles)',
  results26.avg.innerHTMLStyled,
  results27.avg.innerHTMLStyled,
);

analyzeRegression(
  'createElement + style.property',
  results26.avg.createElementWithStyles,
  results27.avg.createElementWithStyles,
);

analyzeRegression(
  'createElement + style.cssText',
  results26.avg.createElementWithCssText,
  results27.avg.createElementWithCssText,
);

analyzeRegression(
  'createElement + setAttribute',
  results26.avg.createElementWithStyleAttribute,
  results27.avg.createElementWithStyleAttribute,
);

See the following StackBlitz

How does similar code behave in browsers?

Didn't notice any performance regressions in browsers recently

What is the problem?

At MUI we recently upgraded JSDOM from 26 to 27 and noticed the runtime of our tests doubled. Part of the problem is slow dom accessors in @testing-library/dom. Another part of it happens in the render phase of our tests. I could narrow it down to @testing-library/react render function, the same slowness was noticable with straight react-dom, we noticed recent changes and quickly assumed it was styling related, then arrived at this reproduction.

Output of the script on my local machine

jsdom Performance Regression - Pure DOM Style Operations
================================================================================
Test: Raw DOM operations with inline styles (no React, no libraries)
================================================================================

Testing jsdom 26.0.0...
Testing jsdom 27.1.0...

================================================================================
RESULTS
================================================================================

jsdom 26.0.0:
  innerHTML (20 simple divs, no styles):     0.426ms
  innerHTML (20 divs with inline styles):    1.249ms
  createElement + style.property (20 divs):  1.053ms
  createElement + style.cssText (20 divs):   0.999ms
  createElement + setAttribute (20 divs):    0.962ms

jsdom 27.1.0:
  innerHTML (20 simple divs, no styles):     0.358ms
  innerHTML (20 divs with inline styles):    44.727ms
  createElement + style.property (20 divs):  6.114ms
  createElement + style.cssText (20 divs):   43.818ms
  createElement + setAttribute (20 divs):    44.261ms

================================================================================
REGRESSION ANALYSIS
================================================================================

innerHTML (simple, no styles):
  Difference: -0.067ms
  Change: -15.8%
  Multiplier: 0.84x

innerHTML (with inline styles):
  Difference: +43.478ms
  Change: +3480.9%
  Multiplier: 35.81x

createElement + style.property:
  Difference: +5.061ms
  Change: +480.5%
  Multiplier: 5.81x

createElement + style.cssText:
  Difference: +42.819ms
  Change: +4286.4%
  Multiplier: 43.86x

createElement + setAttribute:
  Difference: +43.299ms
  Change: +4502.7%
  Multiplier: 46.03x

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions