Skip to content

Commit 6bc89fd

Browse files
Allows hx-live to cache JS eval functions (#3919)
* Allows hx-live to cache JS eval functions * doco update * Add opt-in JavaScript compilation * Add hx-live performance benchmark --------- Co-authored-by: Christian Tanul <git@christiantanul.com>
1 parent 18ec83e commit 6bc89fd

7 files changed

Lines changed: 215 additions & 18 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"check:content": "python3 src/scripts/content/check.py",
3939
"upgrade-check": "python3 src/scripts/upgrade-check.py",
4040
"upgrade-check:test": "python3 src/scripts/upgrade-check.py test/manual/upgrade/",
41+
"benchmark:hx-live": "node test/benchmarks/hx-live.mjs",
4142
"test": "npm run test:chrome",
4243
"test:chrome": "web-test-runner --browsers chromium --config test/web-test-runner.config.mjs --playwright",
4344
"test:firefox": "web-test-runner --browsers firefox --config test/web-test-runner.config.mjs --playwright",

src/ext/hx-live.js

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -535,13 +535,15 @@
535535
ensureActive();
536536
let code = elt.getAttribute(bodyAttr)
537537
let debounce = getDebounce(elt);
538+
let exec;
538539
let run = async () => {
539540
if (!elt.isConnected) {
540541
fns.delete(run);
541542
return;
542543
}
543544
try {
544-
await api.executeJavaScript(elt, { debounce }, code, false);
545+
exec ||= api.executeJavaScript(elt, { debounce }, code, false, true, true);
546+
await exec();
545547
} catch (e) {
546548
if (e !== dbSym) console.error('htmx: hx-live expression threw', e, { elt });
547549
}
@@ -573,26 +575,17 @@
573575
ensureActive();
574576
let debounce = getDebounce(elt);
575577
let isAsync = /\bawait\b/.test(code);
576-
let run = isAsync ? async () => {
578+
let exec;
579+
let run = async () => {
577580
if (!elt.isConnected) {
578581
fns.delete(run);
579582
return;
580583
}
581584
try {
582-
let value = await api.executeJavaScript(elt, { debounce }, code, true);
583-
writeAttrBinding(elt, attrName, value);
584-
observer?.takeRecords();
585-
} catch (e) {
586-
if (e !== dbSym) console.error('htmx: hx-live expression threw', e, { elt, attr: attrName });
587-
}
588-
} : () => {
589-
if (!elt.isConnected) {
590-
fns.delete(run);
591-
return;
592-
}
593-
try {
594-
let value = api.executeJavaScript(elt, { debounce }, code, true, false);
585+
exec ||= api.executeJavaScript(elt, { debounce }, code, true, isAsync, true);
586+
let value = isAsync ? await exec() : exec();
595587
writeAttrBinding(elt, attrName, value);
588+
if (isAsync) observer?.takeRecords();
596589
} catch (e) {
597590
if (e !== dbSym) console.error('htmx: hx-live expression threw', e, { elt, attr: attrName });
598591
}

src/htmx.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -923,7 +923,7 @@ var htmx = (() => {
923923
return bound;
924924
}
925925

926-
__executeJavaScript(thisArg, obj, code, expression = true, isAsync = true) {
926+
__executeJavaScript(thisArg, obj, code, expression = true, isAsync = true, compile = false) {
927927
let args = {}
928928
Object.assign(args, this.__apiMethods(thisArg))
929929
let scope = {};
@@ -934,7 +934,7 @@ var htmx = (() => {
934934
let values = Object.values(args);
935935
let FunctionConstructor = isAsync ? this.#AsyncFunction : this.#Function;
936936
let func = new FunctionConstructor(...keys, expression ? `return (${code})` : code);
937-
return func.call(thisArg, ...values);
937+
return compile ? () => func.call(thisArg, ...values) : func.call(thisArg, ...values);
938938
}
939939

940940
/**

test/benchmarks/hx-live.mjs

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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+
}

test/tests/ext/hx-live.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ describe('hx-live extension', function () {
4242
elt.dataset.v.should.equal('init');
4343
});
4444

45+
it('continues after an invalid expression', function() {
46+
let error = console.error;
47+
console.error = () => {};
48+
try {
49+
playground().innerHTML = '<output :text="("></output><output id="valid" :text="\'ok\'"></output>';
50+
assert.doesNotThrow(() => htmx.process(playground()));
51+
playground().querySelector('#valid').textContent.should.equal('ok');
52+
} finally {
53+
console.error = error;
54+
}
55+
});
56+
4557
it('recomputes on input event', async function() {
4658
playground().innerHTML = `
4759
<input id="src" value="hello">
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
describe('__executeJavaScript', function() {
2+
3+
it('returns the evaluated value by default', function() {
4+
let elt = document.createElement('div');
5+
htmx.__executeJavaScript(elt, { value: 41 }, 'value + 1', true, false).should.equal(42);
6+
});
7+
8+
it('returns a reusable function when compile is true', function() {
9+
let elt = document.createElement('div');
10+
let run = htmx.__executeJavaScript(elt, { value: 41 }, 'value + 1', true, false, true);
11+
assert.isFunction(run);
12+
run().should.equal(42);
13+
run().should.equal(42);
14+
});
15+
16+
});

www/src/content/extensions/06-hx-live.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ A single document-wide `MutationObserver` and `input` / `change` listeners trigg
539539
- `input` or `change` events from any control
540540
- completion of an htmx swap (recomputes pause mid-swap, run once at the end)
541541

542-
All expressions run in a single microtask, so multiple synchronous mutations coalesce into one recompute.
542+
Each expression is pre-compiled once when registered. All pre-compiled expressions then run in a single microtask, so multiple synchronous mutations coalesce into one recompute.
543543

544544
### Self-mutation is safe
545545

0 commit comments

Comments
 (0)