Skip to content

Commit b43a94a

Browse files
committed
Improve hx-live recompute warning
1 parent 0a9c1e6 commit b43a94a

5 files changed

Lines changed: 412 additions & 68 deletions

File tree

src/ext/hx-live.js

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
11
// hx-live extension: reactive live expressions + q() proxy + scope helpers.
22
// Hooks:
33
// htmx:after:process find new [hx-live] elements and register them
4-
// htmx:before:swap increment swap depth (defer recomputes)
5-
// htmx:finally:swap decrement, fire one consolidated recompute
4+
// htmx:before:swap increment swap depth (defer recompute passes)
5+
// htmx:finally:swap decrement, fire one consolidated recompute pass
66
// htmx:scope inject q, wait, trigger, debounce into JS expression scopes
77
(() => {
88
let api;
9-
let fns = new Set();
9+
let liveExpressions = new Set();
1010
let pending = false;
1111
let dbSym = Symbol();
1212
let observer = null;
1313
let recomputeBound = null;
1414
let inputBound = null;
1515
let swaps = 0;
16-
let i = 0;
17-
let start = 0;
18-
let warned = false;
16+
let recomputeMonitor = null;
1917

2018
const OBSERVE_OPTIONS = { childList: true, subtree: true, attributes: true, characterData: true };
19+
const RECOMPUTE_LIMITS = { timePerSecond: 100, passTime: 16 };
2120

2221
let inputDebounceId = null;
2322

2423
function ensureActive() {
2524
if (observer) return;
25+
recomputeMonitor = new RecomputeMonitor();
2626
recomputeBound = () => schedule();
2727
let inputDelay = htmx.parseInterval(htmx.config.live?.inputDebounce ?? 100) ?? 100;
2828
inputBound = () => {
@@ -35,6 +35,49 @@
3535
observer.observe(document.documentElement, OBSERVE_OPTIONS);
3636
}
3737

38+
class RecomputeMonitor {
39+
constructor() {
40+
this.stats = null;
41+
this.overThresholdSeconds = 0;
42+
this.hasWarned = false;
43+
}
44+
45+
start() {
46+
this.startedAt = performance.now();
47+
}
48+
49+
finish(expressionCount) {
50+
let now = performance.now();
51+
let elapsed = now - this.startedAt;
52+
let stats = this.stats ||= { startedAt: now, recomputePassCount: 0, totalTime: 0, longestTime: 0 };
53+
stats.recomputePassCount++;
54+
stats.totalTime += elapsed;
55+
stats.longestTime = Math.max(stats.longestTime, elapsed);
56+
57+
if (now - stats.startedAt > 1000) {
58+
let isOverThreshold = stats.totalTime > RECOMPUTE_LIMITS.timePerSecond || stats.longestTime > RECOMPUTE_LIMITS.passTime;
59+
this.overThresholdSeconds = isOverThreshold ? this.overThresholdSeconds + 1 : 0;
60+
if (!isOverThreshold) this.hasWarned = false;
61+
this.stats = null;
62+
}
63+
if (!this.hasWarned && (elapsed > RECOMPUTE_LIMITS.passTime || this.overThresholdSeconds > 1)) {
64+
this.warn(stats, elapsed > RECOMPUTE_LIMITS.passTime, expressionCount);
65+
this.hasWarned = true;
66+
}
67+
}
68+
69+
warn(stats, longPass, expressionCount) {
70+
let ms = value => `${Math.round(value * 10) / 10}ms`;
71+
let issue = longPass ? `${ms(stats.longestTime)} pass (limit ${RECOMPUTE_LIMITS.passTime}ms)`
72+
: `${ms(stats.totalTime)} work/s (limit ${RECOMPUTE_LIMITS.timePerSecond}ms)`;
73+
let context = `expressions/pass: ${expressionCount.toLocaleString('en-US')}`;
74+
if (!longPass) context += `; average: ${ms(stats.totalTime / stats.recomputePassCount)}/pass`;
75+
console.warn(`warning: hx-live overloaded
76+
${issue}
77+
${context}`);
78+
}
79+
}
80+
3881
function deactivate() {
3982
if (!observer) return;
4083
clearTimeout(inputDebounceId);
@@ -45,27 +88,20 @@
4588
observer.disconnect();
4689
observer = null;
4790
recomputeBound = null;
91+
recomputeMonitor = null;
4892
}
4993

5094
function schedule() {
51-
if (pending) return;
52-
if (swaps > 0) return;
53-
let now = Date.now();
54-
if (now - start > 1000) {
55-
start = now;
56-
i = 0;
57-
warned = false;
58-
}
59-
if (++i > 50 && !warned) {
60-
console.warn('htmx: hx-live recompute exceeded 50/sec.');
61-
warned = true;
62-
}
95+
if (pending || swaps > 0) return;
6396
pending = true;
6497
queueMicrotask(() => {
6598
// Detach observer while writing so our own writes don't queue records.
6699
observer?.disconnect();
67-
fns.forEach(f => f());
68-
if (fns.size === 0) {
100+
let expressionCount = liveExpressions.size;
101+
if (expressionCount > 0) recomputeMonitor.start();
102+
liveExpressions.forEach(run => run());
103+
if (expressionCount > 0) recomputeMonitor.finish(expressionCount);
104+
if (liveExpressions.size === 0) {
69105
deactivate();
70106
} else {
71107
observer.observe(document.documentElement, OBSERVE_OPTIONS);
@@ -528,7 +564,7 @@
528564
function cleanupLive(elt) {
529565
let prop = elt._htmx;
530566
if (!prop?.liveRuns) return;
531-
for (let run of prop.liveRuns) fns.delete(run);
567+
for (let run of prop.liveRuns) liveExpressions.delete(run);
532568
delete prop.liveRuns;
533569
delete prop.liveRegistered;
534570
delete prop.liveAttrs;
@@ -546,7 +582,7 @@
546582
let debounce = getDebounce(elt);
547583
let run = async () => {
548584
if (!elt.isConnected) {
549-
fns.delete(run);
585+
liveExpressions.delete(run);
550586
return;
551587
}
552588
try {
@@ -555,7 +591,7 @@
555591
if (e !== dbSym) console.error('htmx: hx-live expression threw', e, { elt });
556592
}
557593
};
558-
fns.add(run);
594+
liveExpressions.add(run);
559595
prop.liveRuns = prop.liveRuns || new Set();
560596
prop.liveRuns.add(run);
561597
run();
@@ -584,7 +620,7 @@
584620
let isAsync = /\bawait\b/.test(code);
585621
let run = isAsync ? async () => {
586622
if (!elt.isConnected) {
587-
fns.delete(run);
623+
liveExpressions.delete(run);
588624
return;
589625
}
590626
try {
@@ -596,7 +632,7 @@
596632
}
597633
} : () => {
598634
if (!elt.isConnected) {
599-
fns.delete(run);
635+
liveExpressions.delete(run);
600636
return;
601637
}
602638
try {
@@ -606,7 +642,7 @@
606642
if (e !== dbSym) console.error('htmx: hx-live expression threw', e, { elt, attr: bindingName });
607643
}
608644
};
609-
fns.add(run);
645+
liveExpressions.add(run);
610646
let prop = api.htmxProp(elt);
611647
prop.liveRuns = prop.liveRuns || new Set();
612648
prop.liveRuns.add(run);
@@ -664,7 +700,7 @@
664700
swaps++;
665701
},
666702
htmx_finally_swap: () => {
667-
if (--swaps === 0 && fns.size > 0) schedule();
703+
if (--swaps === 0 && liveExpressions.size > 0) schedule();
668704
},
669705
htmx_scope: (elt, detail) => {
670706
Object.assign(detail.scope, {

test/manual/hx-live/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
<body hx-ext="hx-live">
4141

4242
<h1>hx-live playground</h1>
43-
<p class="small">Open DevTools. Type, click, change inputs. Verify state updates without server round-trips.</p>
43+
<p class="small">Open DevTools. Type, click, change inputs. Verify state updates without server round-trips. <a href="recompute-warning.html">Stress-test the recompute warning.</a></p>
4444

4545
<!-- =================================================================== -->
4646
<section>
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>hx-live recompute warning</title>
7+
<script src="../../../src/htmx.js"></script>
8+
<script src="../../../src/ext/hx-live.js"></script>
9+
<style>
10+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
11+
body { max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
12+
button { margin: 0 0.35rem 0.5rem 0; padding: 0.4rem 0.7rem; font: inherit; }
13+
code, pre { padding: 0.15rem 0.35rem; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
14+
pre { min-height: 4.5rem; padding: 0.75rem; overflow-wrap: anywhere; white-space: pre-wrap; }
15+
table { width: 100%; border-collapse: collapse; }
16+
th, td { padding: 0.4rem; border-bottom: 1px solid color-mix(in srgb, CanvasText 20%, Canvas); text-align: left; }
17+
canvas { width: 100%; height: 60px; border: 1px solid color-mix(in srgb, CanvasText 25%, Canvas); }
18+
#stress-bindings { display: grid; grid-template-columns: repeat(10, 1fr); gap: 2px; max-height: 130px; overflow: hidden; }
19+
#stress-bindings output { padding: 2px; background: color-mix(in srgb, CanvasText 6%, Canvas); font: 11px monospace; }
20+
.status { font-weight: 700; }
21+
.note { color: color-mix(in srgb, CanvasText 70%, Canvas); }
22+
</style>
23+
</head>
24+
<body hx-ext="hx-live">
25+
26+
<h1>hx-live recompute warning</h1>
27+
28+
<p>Open DevTools Console, keep this tab visible, then run one scenario. The page also copies the latest hx-live warning below.</p>
29+
30+
<table>
31+
<thead>
32+
<tr><th>Trigger</th><th>Warning</th></tr>
33+
</thead>
34+
<tbody>
35+
<tr><td>One recompute pass above 16ms</td><td>Immediate</td></tr>
36+
<tr><td>Above 100ms total synchronous time in a period</td><td>After two consecutive periods</td></tr>
37+
</tbody>
38+
</table>
39+
40+
<p>A measurement period starts with its first recompute pass and closes on the first pass more than one second later. One high period arms the warning. A second consecutive high period emits it.</p>
41+
42+
<p>hx-live warns once per high-load period. <strong>Stop and reset</strong> removes every binding, deactivates hx-live, and clears that warning state.</p>
43+
44+
<h2>Run a scenario</h2>
45+
46+
<p>
47+
<button id="long-pass">Run one long pass</button>
48+
<button id="heavy-jank">Start heavy binding jank</button>
49+
<button id="sustained-work">Start sustained work</button>
50+
<button id="stop">Stop and reset</button>
51+
</p>
52+
53+
<p id="status" class="status">Stopped. No live expressions are registered.</p>
54+
55+
<canvas id="fps" width="720" height="60" aria-label="Frames per second"></canvas>
56+
<p class="note">The meter shows frame rate. Heavy binding jank should push it well below 60 FPS.</p>
57+
58+
<h2>Latest warning</h2>
59+
<pre id="warning">No warning yet.</pre>
60+
61+
<h2>Live binding output</h2>
62+
<div id="stress-bindings"></div>
63+
64+
<h2>What each scenario does</h2>
65+
<ul>
66+
<li><strong>One long pass:</strong> 200 bindings do about 0.12ms each, once. Expect an immediate warning.</li>
67+
<li><strong>Heavy binding jank:</strong> 200 bindings do about 0.15ms each, every frame. Expect an immediate warning and visible frame loss.</li>
68+
<li><strong>Sustained work:</strong> 50 bindings do about 0.05ms each, every frame. Each pass stays below 16ms, but total work exceeds 100ms per period. Expect a warning after about two seconds.</li>
69+
</ul>
70+
71+
<p class="note">Timings vary by browser and machine. Reload if a preset lands close to a threshold.</p>
72+
73+
<p class="note">From the repository root, run <code>bunx http-server -c-1 .</code>, then open <code>/test/manual/hx-live/recompute-warning.html</code>.</p>
74+
75+
<script>
76+
document.addEventListener('DOMContentLoaded', () => {
77+
let tick = 0;
78+
let workPerBinding = 0;
79+
let animationFrame = null;
80+
let warningCount = 0;
81+
82+
const bindings = document.querySelector('#stress-bindings');
83+
const status = document.querySelector('#status');
84+
const warning = document.querySelector('#warning');
85+
const context = document.querySelector('#fps').getContext('2d');
86+
const originalWarn = console.warn.bind(console);
87+
88+
console.warn = (...args) => {
89+
originalWarn(...args);
90+
if (String(args[0]).startsWith('warning: hx-live overloaded')) {
91+
warningCount++;
92+
warning.textContent = `Warning ${warningCount}\n${args[0]}`;
93+
}
94+
};
95+
96+
window.stressBinding = index => {
97+
let value = tick + index;
98+
let end = performance.now() + workPerBinding;
99+
while (performance.now() < end) value = Math.sqrt(value * value + 1);
100+
return `${index}: ${tick}`;
101+
};
102+
103+
function stop() {
104+
if (animationFrame !== null) cancelAnimationFrame(animationFrame);
105+
animationFrame = null;
106+
}
107+
108+
async function reset() {
109+
stop();
110+
bindings.replaceChildren();
111+
htmx.live.refresh();
112+
await Promise.resolve();
113+
status.textContent = 'Stopped. No live expressions are registered.';
114+
}
115+
116+
function build(bindingCount, bindingWork) {
117+
workPerBinding = bindingWork;
118+
let fragment = document.createDocumentFragment();
119+
for (let index = 0; index < bindingCount; index++) {
120+
let output = document.createElement('output');
121+
output.setAttribute(':text', `stressBinding(${index})`);
122+
fragment.append(output);
123+
}
124+
bindings.replaceChildren(fragment);
125+
htmx.process(bindings);
126+
}
127+
128+
function startFrames() {
129+
let run = () => {
130+
tick++;
131+
htmx.live.refresh();
132+
animationFrame = requestAnimationFrame(run);
133+
};
134+
animationFrame = requestAnimationFrame(run);
135+
}
136+
137+
document.querySelector('#long-pass').addEventListener('click', async () => {
138+
await reset();
139+
build(200, 0.12);
140+
status.textContent = 'Running one pass with 200 expensive bindings.';
141+
htmx.live.refresh();
142+
});
143+
144+
document.querySelector('#heavy-jank').addEventListener('click', async () => {
145+
await reset();
146+
build(200, 0.15);
147+
status.textContent = 'Running 200 expensive bindings every frame.';
148+
startFrames();
149+
});
150+
151+
document.querySelector('#sustained-work').addEventListener('click', async () => {
152+
await reset();
153+
build(50, 0.05);
154+
status.textContent = 'Running 50 moderate bindings every frame.';
155+
startFrames();
156+
});
157+
158+
document.querySelector('#stop').addEventListener('click', reset);
159+
160+
let frames = 0;
161+
let measuredAt = performance.now();
162+
function drawFps(now) {
163+
frames++;
164+
if (now - measuredAt >= 500) {
165+
let fps = Math.round(frames * 1000 / (now - measuredAt));
166+
context.clearRect(0, 0, 720, 60);
167+
context.font = 'bold 30px system-ui';
168+
context.fillStyle = fps < 40 ? '#d33' : '#2a6';
169+
context.fillText(`${fps} FPS`, 12, 40);
170+
frames = 0;
171+
measuredAt = now;
172+
}
173+
requestAnimationFrame(drawFps);
174+
}
175+
requestAnimationFrame(drawFps);
176+
});
177+
</script>
178+
179+
</body>
180+
</html>

0 commit comments

Comments
 (0)