From 26de05551d182990bd53592584965f1102b0a2d8 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Fri, 31 Jul 2026 01:25:36 +0300 Subject: [PATCH] Warn when hx-live expressions take over 16ms --- src/ext/hx-live.js | 19 ++++----- test/tests/ext/hx-live.js | 51 +++++++++++++++--------- www/src/content/extensions/06-hx-live.md | 10 ++++- 3 files changed, 48 insertions(+), 32 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index fc58c4e9c..8934db67c 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -13,8 +13,6 @@ let recomputeBound = null; let inputBound = null; let swaps = 0; - let i = 0; - let start = 0; let warned = false; const OBSERVE_OPTIONS = { childList: true, subtree: true, attributes: true, characterData: true }; @@ -42,26 +40,23 @@ observer.disconnect(); observer = null; recomputeBound = null; + warned = false; } function schedule() { if (pending) return; if (swaps > 0) return; - let now = Date.now(); - if (now - start > 1000) { - start = now; - i = 0; - warned = false; - } - if (++i > 50 && !warned) { - console.warn('htmx: hx-live recompute exceeded 50/sec.'); - warned = true; - } pending = true; queueMicrotask(() => { // Detach observer while writing so our own writes don't queue records. observer?.disconnect(); + let startedAt = performance.now(); fns.forEach(f => f()); + let elapsed = performance.now() - startedAt; + if (!warned && elapsed > 16) { + console.warn(`htmx: hx-live expressions took ${elapsed.toFixed(1)}ms.`); + warned = true; + } if (fns.size === 0) { deactivate(); } else { diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 6c00b2062..e12e89f27 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -342,27 +342,42 @@ describe('hx-live extension', function () { delete window.__swapCountLive; }); - it.skip('iteration cap warns on runaway', async function() { - let warned = false; - let originalWarn = console.warn; - console.warn = (...args) => { - if (typeof args[0] === 'string' && args[0].includes('hx-live recompute exceeded')) warned = true; - originalWarn.apply(console, args); - }; - try { - window.__runawayCountLive = 0; - playground().innerHTML = ''; - htmx.process(playground()); + it('warns once when live expressions take more than 16ms', async function() { + htmx.live.refresh(); + await Promise.resolve(); - for (let i = 0; i < 100; i++) { - document.body.setAttribute('data-runaway-test-live', String(i)); - await htmx.timeout(5); - } + let originalNow = Object.getOwnPropertyDescriptor(performance, 'now'); + let originalWarn = console.warn; + let now = 0; + let elapsed = 0; + let calls = 0; + let warnings = []; + Object.defineProperty(performance, 'now', { + configurable: true, + value: () => calls++ % 2 === 0 ? now : now += elapsed + }); + console.warn = message => warnings.push(message); - warned.should.equal(true); - document.body.removeAttribute('data-runaway-test-live'); - delete window.__runawayCountLive; + try { + createProcessedHTML(''); + + elapsed = 16; + htmx.live.refresh(); + await Promise.resolve(); + warnings.should.deep.equal([]); + + elapsed = 16.1; + htmx.live.refresh(); + await Promise.resolve(); + warnings.should.deep.equal(['htmx: hx-live expressions took 16.1ms.']); + + elapsed = 50; + htmx.live.refresh(); + await Promise.resolve(); + warnings.length.should.equal(1); } finally { + if (originalNow) Object.defineProperty(performance, 'now', originalNow); + else delete performance.now; console.warn = originalWarn; } }); diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index 1680fedde..6bed895b0 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -545,9 +545,15 @@ All expressions run in a single microtask, so multiple synchronous mutations coa When an expression writes to the DOM, the observer drains its own pending records inside the same microtask. Writes made by `hx-live` cannot trigger a feedback loop. -### Runaway cap +### Slow expressions -If recomputes exceed 50/sec, the extension logs a warning. Bindings continue running. Tune your expression or add `debounce`. +After a change, hx-live runs every live expression once. If this takes more than `16ms`, hx-live logs one warning: + +```text +htmx: hx-live expressions took 18.4ms. +``` + +The warning does not stop the expressions. ### Coordinating with htmx swaps