bigskysoftware / bigskysoftware/htmx

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

Open
#3,903 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
49.4k
Forks
1.7k
Avg merge
3d 22h
Merged PRs (30d)
30

Description

`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:

```js
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:

```js
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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.