Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions bench/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM node:20-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
chromium \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*

ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV CHROMIUM_PATH=/usr/bin/chromium

WORKDIR /bench
COPY bench-runner.mjs package.json ./

RUN npm install

ENTRYPOINT ["node", "bench-runner.mjs"]
66 changes: 66 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Style synchronization benchmark

Measures morph time for elements with inline styles to compare
`setAttribute("style")` (master) vs `style.cssText` (fix).

## Quick start (Docker)

```bash
# From the morphdom root directory:
npm run build
docker build -t morphdom-bench ./bench
docker run --rm \
-v "$(pwd)/dist:/morphdom/dist:ro" \
-v "$(pwd)/bench/style-benchmark.html:/bench/style-benchmark.html:ro" \
morphdom-bench
```

To compare branches:

```bash
# 1. Benchmark the fix branch
git checkout fix/csp-safe-style-sync
npm run build
docker run --rm \
-v "$(pwd)/dist:/morphdom/dist:ro" \
-v "$(pwd)/bench/style-benchmark.html:/bench/style-benchmark.html:ro" \
morphdom-bench

# 2. Benchmark master
git checkout master
npm run build
docker run --rm \
-v "$(pwd)/dist:/morphdom/dist:ro" \
-v "$(pwd)/bench/style-benchmark.html:/bench/style-benchmark.html:ro" \
morphdom-bench
```

## Browser (manual)

Open `bench/style-benchmark.html` in any browser. Click "Run extended" for
more precise results (50 iterations with outlier trimming).

## Configuration

The Docker runner accepts environment variables:

- `ITERATIONS` — number of iterations per scenario (default: 30)

```bash
docker run --rm -e ITERATIONS=50 \
-v "$(pwd)/dist:/morphdom/dist:ro" \
-v "$(pwd)/bench/style-benchmark.html:/bench/style-benchmark.html:ro" \
morphdom-bench
```

## Scenarios

| Scenario | Purpose |
| --------------------------------- | --------------------------------------- |
| 1000 els, 5 props, 100% changed | Moderate worst case |
| 1000 els, 20 props, 100% changed | Heavy worst case |
| 1000 els, 5/20 props, 10% changed | Realistic incremental update |
| 1000 els, 20 props, 0% changed | Fast-path (no mutation) |
| 5000 els, 5 props, 100% changed | Scale test |
| 100 els, 20 props + CSS vars | CSS custom properties |
| 1000 els, no style (baseline) | Regression check for non-style elements |
66 changes: 66 additions & 0 deletions bench/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# morphdom style benchmark results

Environment: Docker node:20-slim + Chromium, headless, 30 iterations (trimmed mean)

## master (setAttribute)

| Scenario | Mean (ms) | Median (ms) | StdDev | Min | Max |
| -------------------------------- | --------- | ----------- | ------ | ----- | ----- |
| 1000 els, 5 props, 100% changed | 3.77 | 3.70 | 0.31 | 3.30 | 4.40 |
| 1000 els, 20 props, 100% changed | 5.46 | 5.50 | 0.26 | 5.10 | 6.10 |
| 1000 els, 5 props, 10% changed | 1.79 | 1.70 | 0.22 | 1.50 | 2.30 |
| 1000 els, 20 props, 10% changed | 2.09 | 2.00 | 0.38 | 1.70 | 3.00 |
| 1000 els, 20 props, 0% changed | 1.61 | 1.50 | 0.27 | 1.40 | 2.30 |
| 5000 els, 5 props, 100% changed | 16.80 | 16.20 | 1.82 | 14.70 | 20.90 |
| 100 els, 20 props + CSS vars | 0.67 | 0.70 | 0.06 | 0.60 | 0.80 |
| 1000 els, no style (baseline) | 1.20 | 1.20 | 0.06 | 1.10 | 1.30 |

## fix v1 — syncStyle property-by-property (REJECTED)

| Scenario | Mean (ms) | Median (ms) | StdDev | Min | Max |
| -------------------------------- | --------- | ----------- | ------ | ----- | ----- |
| 1000 els, 5 props, 100% changed | 15.26 | 15.30 | 0.86 | 13.80 | 16.70 |
| 1000 els, 20 props, 100% changed | 61.18 | 61.40 | 1.76 | 57.90 | 63.90 |
| 1000 els, 5 props, 10% changed | 5.66 | 5.50 | 0.33 | 5.40 | 6.30 |
| 1000 els, 20 props, 10% changed | 19.23 | 19.00 | 0.73 | 18.30 | 20.60 |
| 1000 els, 20 props, 0% changed | 15.00 | 15.00 | 0.50 | 14.10 | 16.00 |
| 5000 els, 5 props, 100% changed | 75.53 | 75.40 | 2.93 | 71.50 | 81.60 |
| 100 els, 20 props + CSS vars | 6.93 | 7.00 | 0.25 | 6.50 | 7.50 |
| 1000 els, no style (baseline) | 1.27 | 1.20 | 0.13 | 1.10 | 1.70 |

4-11x slower than master. Rejected due to property-by-property loop overhead.

## fix v3 — getAttribute + style.cssText (CURRENT)

| Scenario | Mean (ms) | Median (ms) | StdDev | Min | Max |
| -------------------------------- | --------- | ----------- | ------ | ----- | ----- |
| 1000 els, 5 props, 100% changed | 5.51 | 5.40 | 0.64 | 4.40 | 7.30 |
| 1000 els, 20 props, 100% changed | 13.73 | 13.70 | 0.73 | 12.70 | 15.90 |
| 1000 els, 5 props, 10% changed | 1.90 | 1.90 | 0.18 | 1.70 | 2.50 |
| 1000 els, 20 props, 10% changed | 2.92 | 2.80 | 0.37 | 2.50 | 3.80 |
| 1000 els, 20 props, 0% changed | 1.57 | 1.50 | 0.26 | 1.40 | 2.30 |
| 5000 els, 5 props, 100% changed | 24.35 | 24.40 | 1.70 | 22.00 | 27.60 |
| 100 els, 20 props + CSS vars | 1.32 | 1.30 | 0.04 | 1.30 | 1.40 |
| 1000 els, no style (baseline) | 1.26 | 1.20 | 0.10 | 1.10 | 1.50 |

## Final comparison (v3 vs master)

| Scenario | master | v3 | Factor |
| -------------------------------- | ------ | ----- | --------------- |
| 1000 els, 5 props, 100% changed | 3.77 | 5.51 | 1.46x slower |
| 1000 els, 20 props, 100% changed | 5.46 | 13.73 | 2.51x slower |
| 1000 els, 5 props, 10% changed | 1.79 | 1.90 | 1.06x (neutral) |
| 1000 els, 20 props, 10% changed | 2.09 | 2.92 | 1.40x slower |
| 1000 els, 20 props, 0% changed | 1.61 | 1.57 | 0.97x (neutral) |
| 5000 els, 5 props, 100% changed | 16.80 | 24.35 | 1.45x slower |
| 100 els, 20 props + CSS vars | 0.67 | 1.32 | 1.97x slower |
| 1000 els, no style (baseline) | 1.20 | 1.26 | 1.05x (neutral) |

## Analysis

- **0% changed (most common in LiveView re-renders): identical to master** (0.97x)
- **10% changed (typical incremental update): ~1.06-1.40x** — negligible
- **100% changed (worst case, all 1000+ elements change all styles): 1.5-2.5x** — the minimum cost of CSP-safe CSSOM setter vs setAttribute
- **No style (baseline): identical** — zero overhead for elements without style
- The remaining overhead in 100% changed is intrinsic to `style.cssText = value` (CSSOM parsing + layout invalidation) vs `setAttribute("style")` (string-only)
- v3 approach: compare via `getAttribute("style")` (cheap string read), write via `style.cssText` (CSP-safe CSSOM setter)
144 changes: 144 additions & 0 deletions bench/bench-runner.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import puppeteer from 'puppeteer-core';
import { readFileSync } from 'fs';
import path from 'path';

const CHROMIUM = process.env.CHROMIUM_PATH || '/usr/bin/chromium';
const ITERATIONS = parseInt(process.env.ITERATIONS || '30', 10);
const MORPHDOM_PATH = '/morphdom/dist/morphdom-umd.js';

const benchHtml = readFileSync('/bench/style-benchmark.html', 'utf8');

// Inject morphdom from mounted volume instead of relative path
const patchedHtml = benchHtml.replace(
'<script src="../dist/morphdom-umd.js"></script>',
'<script>' + readFileSync(MORPHDOM_PATH, 'utf8') + '</script>'
);

const browser = await puppeteer.launch({
executablePath: CHROMIUM,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
headless: true,
});

const page = await browser.newPage();
await page.setContent(patchedHtml, { waitUntil: 'domcontentloaded' });

// Run benchmark inside page context
const results = await page.evaluate((iterations) => {
/* globals morphdom, performance */

var STYLE_PROPS = [
'color', 'background-color', 'padding', 'margin', 'font-size',
'border', 'border-radius', 'opacity', 'display', 'width',
'height', 'max-width', 'min-height', 'line-height', 'letter-spacing',
'text-align', 'overflow', 'position', 'top', 'left'
];
var VALUES_A = [
'red', '#f0f0f0', '10px', '5px', '14px',
'1px solid #ccc', '4px', '1', 'block', '100px',
'50px', '200px', '20px', '1.5', '0.5px',
'left', 'hidden', 'relative', '0px', '0px'
];
var VALUES_B = [
'blue', '#333333', '20px', '15px', '18px',
'2px solid #999', '8px', '0.8', 'flex', '150px',
'80px', '300px', '40px', '1.8', '1px',
'center', 'auto', 'absolute', '10px', '20px'
];

function styleStr(values, count, cssVarSuffix) {
var parts = [];
for (var i = 0; i < count && i < STYLE_PROPS.length; i++) {
parts.push(STYLE_PROPS[i] + ':' + values[i]);
}
return parts.join(';') + (cssVarSuffix || '');
}

function buildTree(elCount, propCount, variant, changeRatio, cssVars) {
var wrap = document.createElement('div');
wrap.id = 'bench-root';
var sA = propCount > 0 ? styleStr(VALUES_A, propCount, cssVars ? ';--value:10;--size:4rem' : '') : null;
var sB = propCount > 0 ? styleStr(VALUES_B, propCount, cssVars ? ';--value:75;--size:6rem' : '') : null;
for (var i = 0; i < elCount; i++) {
var el = document.createElement('div');
el.id = 'item-' + i;
el.textContent = 'item ' + i;
var useB = variant === 'b' && (changeRatio >= 1.0 || (i / elCount) < changeRatio);
var s = useB ? sB : sA;
if (s) el.setAttribute('style', s);
wrap.appendChild(el);
}
return wrap;
}

function measure(sc) {
var times = [];
var container = document.createElement('div');
container.style.display = 'none';
document.body.appendChild(container);

for (var iter = 0; iter < iterations; iter++) {
var from = buildTree(sc.elCount, sc.propCount, 'a', sc.changeRatio, sc.cssVars);
container.textContent = '';
container.appendChild(from);
var to = buildTree(sc.elCount, sc.propCount, 'b', sc.changeRatio, sc.cssVars);
from.offsetHeight; // force layout
var t0 = performance.now();
morphdom(from, to);
var t1 = performance.now();
times.push(t1 - t0);
}
document.body.removeChild(container);

times.sort(function(a, b) { return a - b; });
var trim = Math.floor(times.length * 0.1);
var t = times.slice(trim, times.length - trim);
if (t.length === 0) t = times;
var sum = 0;
for (var i = 0; i < t.length; i++) sum += t[i];
var mean = sum / t.length;
var vari = 0;
for (var i = 0; i < t.length; i++) vari += (t[i] - mean) * (t[i] - mean);
return {
mean: mean,
median: t[Math.floor(t.length / 2)],
stddev: Math.sqrt(vari / t.length),
min: t[0],
max: t[t.length - 1]
};
}

var scenarios = [
{ name: '1000 els, 5 props, 100% changed', elCount: 1000, propCount: 5, changeRatio: 1.0 },
{ name: '1000 els, 20 props, 100% changed', elCount: 1000, propCount: 20, changeRatio: 1.0 },
{ name: '1000 els, 5 props, 10% changed', elCount: 1000, propCount: 5, changeRatio: 0.1 },
{ name: '1000 els, 20 props, 10% changed', elCount: 1000, propCount: 20, changeRatio: 0.1 },
{ name: '1000 els, 20 props, 0% changed', elCount: 1000, propCount: 20, changeRatio: 0.0 },
{ name: '5000 els, 5 props, 100% changed', elCount: 5000, propCount: 5, changeRatio: 1.0 },
{ name: '100 els, 20 props + CSS vars', elCount: 100, propCount: 20, changeRatio: 1.0, cssVars: true },
{ name: '1000 els, no style (baseline)', elCount: 1000, propCount: 0, changeRatio: 0 }
];

var out = [];
for (var s = 0; s < scenarios.length; s++) {
var r = measure(scenarios[s]);
out.push({ name: scenarios[s].name, mean: r.mean, median: r.median, stddev: r.stddev, min: r.min, max: r.max });
}
return out;
}, ITERATIONS);

await browser.close();

// Print results as markdown table
console.log('| Scenario | Mean (ms) | Median (ms) | StdDev | Min | Max |');
console.log('|----------|-----------|-------------|--------|-----|-----|');
for (const r of results) {
console.log(
'| ' + r.name +
' | ' + r.mean.toFixed(2) +
' | ' + r.median.toFixed(2) +
' | ' + r.stddev.toFixed(2) +
' | ' + r.min.toFixed(2) +
' | ' + r.max.toFixed(2) + ' |'
);
}
8 changes: 8 additions & 0 deletions bench/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "morphdom-bench",
"private": true,
"type": "module",
"dependencies": {
"puppeteer-core": "^24.0.0"
}
}
Loading