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
- Enable CSS/JS aggregation.
- 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). - Click the button.
- Expected: The component initializes correctly on the first click.
- Actual: Console errors like
Alpine Expression Error: [ComponentName] is not definedbecause 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
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
Comment #2
niklanComment #3
niklanComment #5
niklanComment #6
niklanComment #7
niklanComment #8
niklanComment #9
smustgrave commentedKnow the change appears small but reading the summary seems like something that should have test coverage for.
Comment #10
niklanFound an additional bug in the patch.
htmx:beforeRequestdoes not fire for programmatichtmx.ajax()calls (only for attribute-triggered requests), sorequestAssetsLoaded.get(detail.xhr)returnsundefinedinhtmx:beforeSwap, causingTypeError: 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 forhtmx:afterSettle.Comment #11
tuutti commentedI couldn't reproduce this with 12.x core after #3555916: Update to htmx 4