Skip to content

Fix/hx-live test unawaited promise - #3902

Merged
scriptogre merged 2 commits into
bigskysoftware:four-devfrom
marciomazza:fix/hx-live-test-unawaited-promise
Jul 20, 2026
Merged

Fix/hx-live test unawaited promise#3902
scriptogre merged 2 commits into
bigskysoftware:four-devfrom
marciomazza:fix/hx-live-test-unawaited-promise

Conversation

@marciomazza

@marciomazza marciomazza commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Description

test/tests/ext/hx-live.js's 'debounce(ms) supersedes prior calls' test writes its hx-live body as a self-invoking async IIFE:

<output hx-live="(async () => { await debounce(20); window.__debounceCountLive++; q('#in').value; })()"></output>

hx-live="..." bodies run with expression=false (__executeJavaScript, htmx.js:920):

let func = new FunctionConstructor(...keys, expression ? `return (${code})` : code);

With expression=false, code becomes the statement body of the generated AsyncFunction — it's executed, not returned. (async () => {...})() creates a promise that nothing in the call chain ever holds a reference to: it's a bare expression statement, and its value is discarded. hx-live.js's run() wraps await api.executeJavaScript(...) in try/catch, but that only covers a rejection of the outer wrapper — which resolves immediately, since its own body never awaits the inner IIFE. The inner IIFE's promise is fully orphaned.

That orphaned promise is exactly the one debounce()'s cancellation mechanism rejects (makeDebounce(), hx-live.js:354-376, rejects the superseded call with a private sentinel). Every real call site that awaits a promise-form debounce() does so directly inside its own try/catch, so this rejection is normally caught and silently discriminated via if (e !== dbSym) console.error(...). This test is the one place a promise-form debounce() ends up not directly awaited by anything, because of the redundant IIFE — so its rejection goes fully unhandled.

In a browser this is cosmetic (an Uncaught (in promise) Symbol() console warning), but it's a genuinely unhandled rejection per spec — any host that treats unhandled rejections as fatal aborts the entire in-flight evaluation when this fires.

Why this is specific to expression=false, not IIFE-wrapping in general: if the same body ran under expression=true (e.g. an :attr="..." binding), the code is compiled as return (${code}). The IIFE's promise becomes the outer AsyncFunction's return value, and per spec, returning a thenable from an async function causes the runtime to implicitly await it before settling the outer function's own promise. So under expression=true the same IIFE pattern would not orphan the rejection — it would propagate correctly to the caller's try/catch. The bug is inherent to the statement-body execution mode that hx-live="..." uses, not to unawaited IIFEs generally.

The fix: hx-live bodies executed with expression=false already run as the body of an AsyncFunction, so top-level await works directly — this is already documented (www/src/content/extensions/15-hx-live.md:164: "await works at the top level (expressions are async functions)"). The IIFE was unnecessary; writing the body as plain statements fixes it:

<output hx-live="await debounce(20); window.__debounceCountLive++; q('#in').value;"></output>

With that change, the test's debounce() promise is awaited directly by run()'s own try/catch, so the cancellation rejection is caught and discriminated via dbSym exactly like every other call site.

Also added a one-line callout to the docs (15-hx-live.md): "Don't leave a promise unawaited — htmx won't see it, and its errors will be swallowed silently."

Testing

Ran npm run test:chrome locally before and after the change: 162 passed, 0 failed, 1 skipped, both before and after — the fix doesn't alter test behavior in a standard browser, since the bug's effect there is silent (console-only).

The bug becomes fatal outside a browser: verified against a downstream embedder (a V8-via-deno_core runtime) that treats unhandled promise rejections as fatal — there, the unfixed test aborts the entire test-file evaluation; with the one-line fix, it passes cleanly.

When running the tests in the browser, this appears in the console:

screenshot 2026-07-19 09-02-33

Suggestion for catching regressions like this in CI (not implemented in this PR): because this failure mode is silent in a real browser, npm run test would not catch a reintroduction of this pattern. A window.addEventListener('unhandledrejection', ...) check could be added to fail a test if it leaves any unhandled rejection behind. Note if this is picked up later: a naive implementation via a shared beforeEach/afterEach hook has a real pitfall — Mocha fails all remaining tests in the suite when a hook throws (confirmed: a hook-level failure here cascaded to 143 failing tests, versus 1 for an equivalent plain assertion failure). I didn't implement any of this, since it affects the test flow in a major way and needs pondering.

Checklist

  • I have read the contribution guidelines
  • I have targeted this PR against the correct branch (master for website changes, dev for source changes)
  • This is either a bugfix, a documentation update, or a new feature that has been explicitly approved via an issue
  • I ran the test suite locally (npm run test) and verified that it succeeded

The test wrapped its hx-live body in a self-invoking async IIFE.
hx-live bodies run with expression=false, so the body statements
execute directly as an AsyncFunction body (top-level await already
works) -- the IIFE's own promise was never awaited or caught by
anything. debounce()'s cancellation mechanism rejects the superseded
promise, and with no one holding a reference to the IIFE's promise,
that rejection went fully unhandled, which crashes strict hosts
(e.g. deno_core) on unhandled rejection.

Write the body as plain statements instead of a self-invoking IIFE.
@marciomazza
marciomazza changed the base branch from master to four-dev July 19, 2026 12:47
@marciomazza

Copy link
Copy Markdown
Contributor Author

Sorry about the target branch confusion: I corrected it to point to four-dev.

@scriptogre

Copy link
Copy Markdown
Collaborator

Thanks for tracking this down. The test fix looks good.

I pushed a small docs follow-up to your branch. It moves the warning into Notes on both the hx-live and hx-on pages, with short good/bad examples.

@scriptogre
scriptogre merged commit 1d0cc93 into bigskysoftware:four-dev Jul 20, 2026
3 checks passed
@marciomazza

Copy link
Copy Markdown
Contributor Author

Great. Thanks!

@MichaelWest22

Copy link
Copy Markdown
Collaborator
    window.addEventListener('unhandledrejection', e => {
        if (e.reason === dbSym) e.preventDefault();
    });

maybe we should add this in to catch this unhandled rejection. We have to use this symbol just to handle canceling the debounce which is the only way to abort it in JS. It is not a real error just using it as the only escape hatch we have. So ideally we should be catching and ignoring this non error. I don't know if the documentation we added is 100% accurate as while top level async is now the best way to handle things the wrapped versions still work fine most of the time and it is only this one debounce unhandled exception issue that the wrapped form highlights but other uses while more code don't cause issues.

@scriptogre

Copy link
Copy Markdown
Collaborator

I didn't give it deep though. Feel free to completely replace my added note from docs.

@marciomazza
marciomazza deleted the fix/hx-live-test-unawaited-promise branch July 31, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants