Problem/Motivation

In htmx-assets.js, the htmx:beforeSwap handler calls Drupal.htmx.addAssets() via a .then() chain but does not store the resulting promise back into the requestAssetsLoaded WeakMap:

requestAssetsLoaded.get(detail.xhr).then(() => Drupal.htmx.addAssets(data));

The htmx:afterSettle handler then retrieves the original Promise.resolve() (set during htmx:beforeRequest) and immediately fires htmx:drupal:load without waiting for asset loading to complete:

(requestAssetsLoaded.get(detail.xhr) || Promise.resolve()).then(() => {
  htmx.trigger(detail.elt.parentNode, 'htmx:drupal:load');
  requestAssetsLoaded.delete(detail.xhr);
});

This causes a race condition: htmx:drupal:load (and therefore Drupal behavior attachment) fires before the new JS files delivered in the HTMX response have been loaded by loadjs.

When JS aggregation is disabled, individual files are typically browser-cached and resolve near-instantly, masking the bug. When JS aggregation is enabled, the race condition becomes apparent due to two factors: the aggregated bundle may not yet exist and must be lazily generated on first request, and even when already generated, aggregated files are larger and take longer to download. The issue is easily reproducible with network throttling (e.g., Chrome DevTools "Slow 3G").

Steps to reproduce

  1. Enable CSS/JS aggregation.
  2. Have a page with a button that loads a dialog via HTMX with AlpineJS component inside, e.g.:
    <button hx-get="/my-dialog" hx-target="body" hx-swap="beforeend" data-hx-drupal-only-main-content>Open dialog</button>
    where the response includes JS assets not yet present on the page (e.g., a library with a dialog component).
  3. Click the button.
  4. Expected: The component initializes correctly on the first click.
  5. Actual: Console errors like Alpine Expression Error: [ComponentName] is not defined because the JS file registering the Alpine component hasn't been loaded yet when Alpine tries to initialize the new DOM elements. A second click always works because the assets from the first request have already loaded by then.

It is worth mentioning that we have a thin Alpine.js + HTMX integration layer that defers Alpine mutations on htmx:beforeSwap and flushes them on htmx:drupal:load. Because the event fires too early, Alpine processes the new DOM before the JS defining its components has loaded.

Proposed resolution

Store the chained promise back into the requestAssetsLoaded WeakMap so that htmx:afterSettle waits for asset loading to complete before firing htmx:drupal:load.

Remaining tasks

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

Issue fork drupal-3578459

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

niklan created an issue. See original summary.

niklan’s picture

Issue summary: View changes
niklan’s picture

Issue summary: View changes

niklan’s picture

Status: Active » Needs review
niklan’s picture

niklan’s picture

niklan’s picture

Component: other » ajax system
smustgrave’s picture

Status: Needs review » Needs work
Issue tags: +Needs tests

Know the change appears small but reading the summary seems like something that should have test coverage for.

niklan’s picture

Found an additional bug in the patch. htmx:beforeRequest does not fire for programmatic htmx.ajax() calls (only for attribute-triggered requests), so requestAssetsLoaded.get(detail.xhr) returns undefined in htmx:beforeSwap, causing TypeError: undefined is not an object (evaluating 'requestAssetsLoaded.get(detail.xhr).then').

Replaced requestAssetsLoaded.get(detail.xhr).then(...) with (requestAssetsLoaded.get(detail.xhr) || Promise.resolve()).then(...) . Same approach is used for htmx:afterSettle.

tuutti’s picture

I couldn't reproduce this with 12.x core after #3555916: Update to htmx 4