Skip to content

Commit 17c46ed

Browse files
committed
Improve hx-live recompute warning
1 parent bfe656c commit 17c46ed

5 files changed

Lines changed: 405 additions & 68 deletions

File tree

src/ext/hx-live.js

Lines changed: 58 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 = makeRecomputeMonitor();
2626
recomputeBound = () => schedule();
2727
let inputDelay = htmx.parseInterval(htmx.config.live?.inputDebounce ?? 100) ?? 100;
2828
inputBound = () => {
@@ -35,6 +35,44 @@
3535
observer.observe(document.documentElement, OBSERVE_OPTIONS);
3636
}
3737

38+
function makeRecomputeMonitor() {
39+
let periodStartedAt = null;
40+
let recomputePassCount = 0;
41+
let totalTime = 0;
42+
let hadLongPass = false;
43+
let overThresholdSeconds = 0;
44+
let hasWarned = false;
45+
46+
return (startedAt, expressionCount) => {
47+
let now = performance.now();
48+
let elapsed = now - startedAt;
49+
periodStartedAt ??= now;
50+
recomputePassCount++;
51+
totalTime += elapsed;
52+
let longPass = elapsed > RECOMPUTE_LIMITS.passTime;
53+
hadLongPass ||= longPass;
54+
55+
let periodFinished = now - periodStartedAt > 1000;
56+
if (periodFinished) {
57+
let isOverThreshold = totalTime > RECOMPUTE_LIMITS.timePerSecond || hadLongPass;
58+
overThresholdSeconds = isOverThreshold ? overThresholdSeconds + 1 : 0;
59+
if (!isOverThreshold) hasWarned = false;
60+
}
61+
if (!hasWarned && (longPass || overThresholdSeconds > 1)) {
62+
let detail = longPass ? `${elapsed.toFixed(1)}ms pass`
63+
: `${totalTime.toFixed(1)}ms/s; ${(totalTime / recomputePassCount).toFixed(1)}ms/pass`;
64+
console.warn(`htmx: hx-live overloaded: ${detail}; ${expressionCount} expr/pass`);
65+
hasWarned = true;
66+
}
67+
if (periodFinished) {
68+
periodStartedAt = null;
69+
recomputePassCount = 0;
70+
totalTime = 0;
71+
hadLongPass = false;
72+
}
73+
};
74+
}
75+
3876
function deactivate() {
3977
if (!observer) return;
4078
clearTimeout(inputDebounceId);
@@ -45,27 +83,20 @@
4583
observer.disconnect();
4684
observer = null;
4785
recomputeBound = null;
86+
recomputeMonitor = null;
4887
}
4988

5089
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-
}
90+
if (pending || swaps > 0) return;
6391
pending = true;
6492
queueMicrotask(() => {
6593
// Detach observer while writing so our own writes don't queue records.
6694
observer?.disconnect();
67-
fns.forEach(f => f());
68-
if (fns.size === 0) {
95+
let expressionCount = liveExpressions.size;
96+
let startedAt = expressionCount > 0 ? performance.now() : 0;
97+
liveExpressions.forEach(run => run());
98+
if (expressionCount > 0) recomputeMonitor(startedAt, expressionCount);
99+
if (liveExpressions.size === 0) {
69100
deactivate();
70101
} else {
71102
observer.observe(document.documentElement, OBSERVE_OPTIONS);
@@ -522,7 +553,7 @@
522553
function cleanupLive(elt) {
523554
let prop = elt._htmx;
524555
if (!prop?.liveRuns) return;
525-
for (let run of prop.liveRuns) fns.delete(run);
556+
for (let run of prop.liveRuns) liveExpressions.delete(run);
526557
delete prop.liveRuns;
527558
delete prop.liveRegistered;
528559
delete prop.liveAttrs;
@@ -540,7 +571,7 @@
540571
let debounce = getDebounce(elt);
541572
let run = async () => {
542573
if (!elt.isConnected) {
543-
fns.delete(run);
574+
liveExpressions.delete(run);
544575
return;
545576
}
546577
try {
@@ -549,7 +580,7 @@
549580
if (e !== dbSym) console.error('htmx: hx-live expression threw', e, { elt });
550581
}
551582
};
552-
fns.add(run);
583+
liveExpressions.add(run);
553584
prop.liveRuns = prop.liveRuns || new Set();
554585
prop.liveRuns.add(run);
555586
run();
@@ -578,7 +609,7 @@
578609
let isAsync = /\bawait\b/.test(code);
579610
let run = isAsync ? async () => {
580611
if (!elt.isConnected) {
581-
fns.delete(run);
612+
liveExpressions.delete(run);
582613
return;
583614
}
584615
try {
@@ -590,7 +621,7 @@
590621
}
591622
} : () => {
592623
if (!elt.isConnected) {
593-
fns.delete(run);
624+
liveExpressions.delete(run);
594625
return;
595626
}
596627
try {
@@ -600,7 +631,7 @@
600631
if (e !== dbSym) console.error('htmx: hx-live expression threw', e, { elt, attr: bindingName });
601632
}
602633
};
603-
fns.add(run);
634+
liveExpressions.add(run);
604635
let prop = api.htmxProp(elt);
605636
prop.liveRuns = prop.liveRuns || new Set();
606637
prop.liveRuns.add(run);
@@ -658,7 +689,7 @@
658689
swaps++;
659690
},
660691
htmx_finally_swap: () => {
661-
if (--swaps === 0 && fns.size > 0) schedule();
692+
if (--swaps === 0 && liveExpressions.size > 0) schedule();
662693
},
663694
htmx_scope: (elt, detail) => {
664695
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)