|
| 1 | +import path from 'node:path' |
| 2 | +import { parseArgs } from 'node:util' |
| 3 | +import { chromium } from 'playwright' |
| 4 | + |
| 5 | +const { values } = parseArgs({ |
| 6 | + options: { |
| 7 | + case: { type: 'string', multiple: true, default: [] }, |
| 8 | + rounds: { type: 'string', default: '6' }, |
| 9 | + samples: { type: 'string', default: '20' } |
| 10 | + } |
| 11 | +}) |
| 12 | + |
| 13 | +const cases = values.case.map(value => { |
| 14 | + let separator = value.indexOf('=') |
| 15 | + if (separator < 1 || separator === value.length - 1) { |
| 16 | + throw new Error(`Invalid case: ${value}. Use name=path.`) |
| 17 | + } |
| 18 | + return { |
| 19 | + name: value.slice(0, separator), |
| 20 | + root: path.resolve(value.slice(separator + 1)) |
| 21 | + } |
| 22 | +}) |
| 23 | + |
| 24 | +if (cases.length < 2) { |
| 25 | + console.error('Usage: bun run benchmark:hx-live -- --case <name=repo> --case <name=repo> [...]') |
| 26 | + process.exit(1) |
| 27 | +} |
| 28 | + |
| 29 | +const rounds = Number(values.rounds) |
| 30 | +const samples = Number(values.samples) |
| 31 | +const counts = [100, 500, 1000] |
| 32 | + |
| 33 | +const percentile = (values, value) => { |
| 34 | + let sorted = values.toSorted((a, b) => a - b) |
| 35 | + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * value))] |
| 36 | +} |
| 37 | + |
| 38 | +async function runRound(browser, root, expressionCount) { |
| 39 | + let page = await browser.newPage() |
| 40 | + await page.addScriptTag({ path: path.join(root, 'src/htmx.js') }) |
| 41 | + await page.evaluate(() => { |
| 42 | + htmx.config.extensions = 'hx-live' |
| 43 | + htmx.__approvedExt = 'hx-live' |
| 44 | + }) |
| 45 | + await page.addScriptTag({ path: path.join(root, 'src/ext/hx-live.js') }) |
| 46 | + |
| 47 | + let result = await page.evaluate(async ({ expressionCount, samples }) => { |
| 48 | + let rows = expressionCount / 10 |
| 49 | + document.body.innerHTML = ` |
| 50 | + <input id="quantity" type="number" value="2"> |
| 51 | + ${Array.from({ length: rows }, (_, index) => ` |
| 52 | + <article class="row" data-price="${index + 1}"> |
| 53 | + <output :text="q('#quantity').value * data.price"></output> |
| 54 | + <button :disabled="q('#quantity').value == 0">Buy</button> |
| 55 | + <span :.bulk="q('#quantity').value >= 10">Bulk</span> |
| 56 | + <span :aria-hidden="q('#quantity').value == 0">Details</span> |
| 57 | + <output :data-total="q('#quantity').value * data.price"></output> |
| 58 | + <input :value="q('#quantity').value"> |
| 59 | + <input type="checkbox" :checked="q('#quantity').value == 0"> |
| 60 | + <span class="state-marker" :class="{ zero: q('#quantity').value == 0, bulk: q('#quantity').value >= 10 }"></span> |
| 61 | + <span :style="{ opacity: q('#quantity').value == 0 ? 0.5 : 1 }"></span> |
| 62 | + <output :html="'<b>' + q('#quantity').value + '</b>'"></output> |
| 63 | + </article> |
| 64 | + `).join('')} |
| 65 | + ` |
| 66 | + |
| 67 | + let start = performance.now() |
| 68 | + htmx.process(document.body) |
| 69 | + let registration = performance.now() - start |
| 70 | + |
| 71 | + let found = [...document.querySelectorAll('.row *')] |
| 72 | + .flatMap(elt => elt.getAttributeNames()) |
| 73 | + .filter(name => name.startsWith(':')).length |
| 74 | + if (found !== expressionCount) throw new Error(`Expected ${expressionCount} expressions, found ${found}`) |
| 75 | + |
| 76 | + let refresh = async () => { |
| 77 | + htmx.live.refresh() |
| 78 | + // Flush the scheduled run and async binding continuations. |
| 79 | + await Promise.resolve() |
| 80 | + await Promise.resolve() |
| 81 | + } |
| 82 | + |
| 83 | + await new Promise(resolve => setTimeout(resolve)) |
| 84 | + for (let index = 0; index < 5; index++) await refresh() |
| 85 | + |
| 86 | + let unchanged = [] |
| 87 | + for (let index = 0; index < samples; index++) { |
| 88 | + start = performance.now() |
| 89 | + await refresh() |
| 90 | + unchanged.push(performance.now() - start) |
| 91 | + } |
| 92 | + |
| 93 | + let changed = [] |
| 94 | + let quantity = document.querySelector('#quantity') |
| 95 | + let row = document.querySelector('.row') |
| 96 | + let [text, total, html] = row.querySelectorAll('output') |
| 97 | + let button = row.querySelector('button') |
| 98 | + let bulk = row.querySelector('span') |
| 99 | + let details = row.querySelector('span[aria-hidden]') |
| 100 | + let [valueInput, checkedInput] = row.querySelectorAll('input') |
| 101 | + let state = row.querySelector('.state-marker') |
| 102 | + let styled = row.querySelector('span[style]') |
| 103 | + |
| 104 | + for (let index = 0; index < samples; index++) { |
| 105 | + let value = index % 2 ? 0 : 10 |
| 106 | + quantity.value = value |
| 107 | + start = performance.now() |
| 108 | + await refresh() |
| 109 | + changed.push(performance.now() - start) |
| 110 | + |
| 111 | + if (text.textContent !== String(value)) throw new Error('Text binding did not finish') |
| 112 | + if (button.disabled !== (value === 0)) throw new Error('Boolean binding did not finish') |
| 113 | + if (bulk.classList.contains('bulk') !== (value >= 10)) throw new Error('Class binding did not finish') |
| 114 | + if (details.getAttribute('aria-hidden') !== String(value === 0)) throw new Error('ARIA binding did not finish') |
| 115 | + if (total.dataset.total !== String(value)) throw new Error('Attribute binding did not finish') |
| 116 | + if (valueInput.value !== String(value)) throw new Error('Value binding did not finish') |
| 117 | + if (checkedInput.checked !== (value === 0)) throw new Error('Checked binding did not finish') |
| 118 | + if (state.classList.contains('zero') !== (value === 0)) throw new Error('Object class binding did not finish') |
| 119 | + if (state.classList.contains('bulk') !== (value >= 10)) throw new Error('Object class binding did not finish') |
| 120 | + if (styled.style.opacity !== (value === 0 ? '0.5' : '1')) throw new Error('Style binding did not finish') |
| 121 | + if (html.innerHTML !== `<b>${value}</b>`) throw new Error('HTML binding did not finish') |
| 122 | + } |
| 123 | + |
| 124 | + return { registration, unchanged, changed } |
| 125 | + }, { expressionCount, samples }) |
| 126 | + await page.close() |
| 127 | + return result |
| 128 | +} |
| 129 | + |
| 130 | +const emptyMeasurements = () => ({ registration: [], unchanged: [], changed: [] }) |
| 131 | +for (let entry of cases) { |
| 132 | + entry.results = new Map(counts.map(count => [count, emptyMeasurements()])) |
| 133 | +} |
| 134 | + |
| 135 | +const format = values => `${percentile(values, 0.5).toFixed(2)} / ${percentile(values, 0.95).toFixed(2)}` |
| 136 | +const speedup = (before, after) => `${(percentile(before, 0.5) / percentile(after, 0.5)).toFixed(1)}×` |
| 137 | + |
| 138 | +let browser = await chromium.launch({ headless: true }) |
| 139 | +try { |
| 140 | + for (let entry of cases) console.log(`${entry.name}: ${entry.root}`) |
| 141 | + console.log(`Samples: ${rounds} rounds × ${samples}\n`) |
| 142 | + |
| 143 | + for (let expressionCount of counts) { |
| 144 | + for (let round = 0; round < rounds; round++) { |
| 145 | + let offset = round % cases.length |
| 146 | + let order = [...cases.slice(offset), ...cases.slice(0, offset)] |
| 147 | + for (let entry of order) { |
| 148 | + let run = await runRound(browser, entry.root, expressionCount) |
| 149 | + let result = entry.results.get(expressionCount) |
| 150 | + result.registration.push(run.registration) |
| 151 | + result.unchanged.push(...run.unchanged) |
| 152 | + result.changed.push(...run.changed) |
| 153 | + } |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + console.log('Times are p50 / p95 milliseconds. Speedups compare with the first case.') |
| 158 | + for (let expressionCount of counts) { |
| 159 | + let before = cases[0].results.get(expressionCount) |
| 160 | + console.log(`\n${expressionCount} expressions`) |
| 161 | + console.table(cases.map(entry => { |
| 162 | + let result = entry.results.get(expressionCount) |
| 163 | + return { |
| 164 | + case: entry.name, |
| 165 | + registration: format(result.registration), |
| 166 | + unchanged: format(result.unchanged), |
| 167 | + changed: format(result.changed), |
| 168 | + 'unchanged speedup': speedup(before.unchanged, result.unchanged), |
| 169 | + 'changed speedup': speedup(before.changed, result.changed) |
| 170 | + } |
| 171 | + })) |
| 172 | + } |
| 173 | +} finally { |
| 174 | + await browser.close() |
| 175 | +} |
0 commit comments