Skip to content

hx-live: runaway-recompute warning is a page-wide one-shot cap, silently swallowing warnings for other quick recompute expressions #3903

Description

@marciomazza

src/ext/hx-live.js's runaway-recompute guard (schedule(), lines ~39–51) tracks i, start, and warned as module-level state shared across every hx-live/:expr binding on the page:

let swaps = 0;
let i = 0;
let start = 0;
let 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;
    }
    ...
}

warned is a one-shot latch per rolling 1-second window, shared globally. Once any expression on the page trips it, console.warn is suppressed for every other expression until the window rolls over (now - start > 1000).

Impact: if two independently-broken hx-live expressions both start looping out of control within the same second, only the first gets a console warning. The second burns CPU in a runaway loop with zero diagnostic signal, because warned is already true and stays that way until the window resets. schedule() has no notion of which expression/element triggered a given recompute, so there's no way to tell offenders apart with the current state shape.

Where I hit this:

I'm running test/tests/ext/hx-live.js (the vendored htmx test suite) against a custom test runner — a mocha-shim executing inside a V8 runtime with happy-dom, rather than a real browser. That runner follows the same structure as the upstream test file itself: the extension script is loaded once in the suite's before() hook, so all module-level state in hx-live.js persists across every it() for the rest of the file, not just the currently-running test.

Concretely, the iteration cap warns on runaway test (in the hx-live extension describe block) asserts:

it('iteration cap warns on runaway', async function() {
    let warned = false;
    console.warn = (...args) => { if (/* matches message */) warned = true; ... };
    for (let i = 0; i < 100; i++) {
        document.body.setAttribute('data-runaway-test-live', String(i));
        await htmx.timeout(5);
    }
    warned.should.equal(true);
});

This test passes reliably when run in isolation. But run as part of the full suite, it fails deterministically: expected false to equal true. Root cause is exactly the shared-latch design above — several earlier tests in the same file (coalesces recomputes during a swap, multiple hx-live elements all run, etc.) collectively push the recompute counter past 50 and set warned = true within the same 1-second window this test starts in, so its own 100-iteration burst never gets a fresh warning. The underlying reactivity is unaffected — window.__runawayCountLive still reaches ~101 in the failing run, confirming every mutation still triggered a recompute — only the console.warn side channel is lost.

I'm currently working around this on my side by skipping that one test (it's an order-dependent false negative, not a functional bug), but it seemed worth reporting upstream since the same shared-state design would silently swallow a warning for a second real runaway loop in production, not just in a test harness.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions