Problem/Motivation
On Drupal 11.4, an element with #ajax that is rendered through a BigPipe placeholder does nothing when clicked. No request, no JS error, no log.
I found two different bugs, and both need a fix or the button stays dead.
- The commands order. BigPipe runs settings, add_css, add_js and insert. The add_js command triggers htmx:drupal:load, that calls Drupal.attachBehaviors() and binds the ajax handler to the button. After that, insert replaces the element using outerHTML, so the handler stays on the old element and the new one has nothing bound.
- Drupal.htmx.mergeSettings() overwrites the key when the incoming value is an array, even if the array is empty. BigPipe sends one settings command per placeholder, and a placeholder without #ajax elements sends [] for ajax. So a later placeholder can wipe drupalSettings.ajax that an earlier one just added.
To reproduce, use a page with two BigPipe placeholders, one with a form with an #ajax submit button and another one without #ajax. Then Object.keys(drupalSettings.ajax) is empty, the button data-once is null and clicking does nothing. With only one placeholder, bug 1 still happens.
The patch does two things. insert() in big_pipe.commands.js triggers htmx:drupal:load again on the swapped targets after the swap (once() makes it safe to run twice). mergeSettings() skips an empty array when the existing value is already a non empty object or array.
I added a FunctionalJavascript test to BigPipeRegressionTest, but I could not run it on my setup (Redis lock service and no WebDriver). I tested with Playwright, after the patch data-once is set, drupalSettings.ajax keeps its key and the click sends the AJAX requests. A page with 26 ajax forms that worked before still works.
The patch was added first as I needed this fix asap, I created the MR after it.
All stuff below was written by AI, I did not remove it as I think there's probably util information there, and I added a simplified summary above this.
Drupal 11's htmx-based BigPipe placeholder-replacement pipeline
(core/modules/big_pipe/js/big_pipe.commands.js + core/misc/htmx/) has
two independent bugs that combine to make any #ajax-driven element resolved through
a BigPipe placeholder silently inert - no bound click handler, no error, no failed request, nothing
in any log.
Bug 1: attach-before-swap command ordering
The add_js command handler fires a custom htmx:drupal:load event on
document.body as soon as its own asset-loading promise resolves
(big_pipe.commands.js:165-169). That event triggers
Drupal.attachBehaviors(document.body, drupalSettings)
(core/misc/htmx/htmx-behaviors.js:14-18), which is what binds a classic #ajax-driven
element to its handler via Drupal.behaviors.AJAX.attach()
(core/misc/ajax.js:37-78, using once('drupal-ajax', ...)).
BigPipe's command queue runs strictly in order (typically
settings, add_css,
add_js, insert, in that order), and each command's own promise must fully resolve before the next
command starts (Drupal.bigPipe.commandExecutionQueue(),
big_pipe.commands.js:19-36). So add_js's attach always completes
before insert runs. insert
(big_pipe.commands.js:56-83) then replaces the target element wholesale via an
outerHTML/replaceWith swap (
htmx.swap(target, data, { swapStyle:
'outerHTML' })), discarding the exact node the click handler was just bound to, and inserting
a fresh, unbound replacement. Nothing re-triggers attach after insert completes.
Bug 2: mergeSettings() empty-array clobber
Separately, and just as impactful on its own: Drupal.htmx.mergeSettings()
(core/misc/htmx/htmx-utils.js) merges each settings command's payload into
drupalSettings. When the incoming value at a key is a JSON array, the merge function
unconditionally does current[key] = mergeSettings(new Array(value.length), value) -
even when the incoming array is empty, overwriting whatever was already there.
BigPipe sends one settings command per placeholder on the page. A placeholder with no
entries for a given settings key (for example a block with no #ajax elements of its own) serializes
its empty associative PHP array as JSON []. On a page with multiple placeholders, any
placeholder resolved after the one that legitimately populated
drupalSettings.ajax will wipe that key back to nothing, via this same code path -
regardless of bug 1.
These are independent: fixing only bug 1 still leaves the button unbound, because
by the time any attach runs (early or late), drupalSettings.ajax has already been wiped
by a later placeholder's settings command. Fixing only bug 2 still leaves the button
unbound on a page with exactly one placeholder, because the attach still runs against the wrong
(about-to-be-discarded) node. Both need fixing.
Versions / environment tested
- Drupal core: 11.4.5 (
drupal/core-recommended) - PHP: 8.4.22
- Commerce (contextual only, not required to reproduce - see "Steps to reproduce"):
drupal/commerce3.3.8 - Browser tested: Chromium (Playwright), headless - the defect is in command sequencing and a
JSON-serialization edge case, not a rendering-engine quirk, so it's expected to be
browser-independent - Browser tested, manual test: Google Chrome Versão 152.0.7977.64 (Versão oficial) (arm64)
big_pipecore module: enabled, no BigPipe-related contrib modules installed - 100%
core JS
Steps to reproduce
- Enable
big_pipe(enabled by default in most installs). - Create a page with at least two BigPipe placeholders: one containing an
#ajax-driven element (e.g. a #lazy_builder/#create_placeholder render array producing a form with):$form['actions']['submit'] = [ '#type' => 'submit', '#value' => t('Test'), '#ajax' => ['callback' => '::ajaxCallback', 'wrapper' => 'test-wrapper'], ];and at least one other, unrelated placeholder with no #ajax elements of its own (any lazy builder
returning plain markup is enough - most real pages already have several, e.g. blocks, menus,
breadcrumbs). - Load the page as an authenticated user with a session (BigPipe requires a session to engage its
placeholder strategy for most content). - After the page fully loads, in devtools:
Object.keys(drupalSettings.ajax)- expected: contains the button's id/base.
Actual: empty (bug 2 - a later placeholder's ownsettingscommand wiped
it, even though the button's ownsettingscommand correctly merged it in first).document.getElementById('your-button-id').getAttribute('data-once')- expected
non-null if bound. Actual: null (bug 1).- Click the button - expected an AJAX request. Actual: nothing happens, no
request, no console error.
With only a single placeholder on the page (bug 2 doesn't apply, nothing to clobber the setting),
bug 1 alone still reproduces the data-once: null / inert-click symptom.
Proposed resolution
A patch is attached (bigpipe-htmx-ajax-command-ordering.patch) with both fixes:
insert()(core/modules/big_pipe/js/big_pipe.commands.js) re-queries its
(now-swapped)selectortargets after the DOM swap and re-triggers
htmx:drupal:loadon each, so behaviors attach to what's actually in the DOM rather than
relying solely onadd_js's earlier, too-early trigger. No-op for content the swap didn't
touch, since Drupal'sonce()markers make re-attaching idempotent.mergeSettings()(core/misc/htmx/htmx-utils.js)'s array-merge branch
skips the assignment when the incoming array is empty and the existing value at that key is already a
non-empty object/array, so an empty array can no longer clobber real data merged in by an earlier
placeholder'ssettingscommand.
Code citations for the affected (unmodified core) locations, for reference:
core/modules/big_pipe/js/big_pipe.commands.js:19-36-
Drupal.bigPipe.commandExecutionQueue(), executes commands strictly in order via a
promise-chainedreduce().core/modules/big_pipe/js/big_pipe.commands.js:56-83- theinsert
command; note theouterHTML/replaceWithswap style mapping and the
pre-swaphtmx.trigger(target, 'htmx:drupal:unload')on the old
target.core/modules/big_pipe/js/big_pipe.commands.js:110-114- thesettings
command; callsDrupal.htmx.mergeSettings(drupalSettings, settings)when
merge: true.core/modules/big_pipe/js/big_pipe.commands.js:165-169- theadd_js
command; fireshtmx.trigger(document.body, 'htmx:drupal:load')after assets resolve,
always beforeinserthas run for the same placeholder.core/misc/htmx/htmx-behaviors.js:14-18- thehtmx:drupal:load
listener; callsDrupal.attachBehaviors(detail.elt, drupalSettings).core/misc/htmx/htmx-behaviors.js:22-24- thehtmx:drupal:unload
listener; callsDrupal.detachBehaviors(detail.elt, drupalSettings, 'unload').core/misc/ajax.js:37-78-Drupal.behaviors.AJAX.attach(), reads
settings.ajax, resolves each entry's selector, and calls
once('drupal-ajax', selector)+Drupal.ajax(elementSettings).core/misc/htmx/htmx-utils.js-Drupal.htmx.mergeSettings(), the
[object Array]branch (bug 2's location).
A FunctionalJavascript regression test is included in the patch, added to core's own
big_pipe test suite
(BigPipeRegressionTest::testAjaxBehaviorsAttachAfterPlaceholderReplacement(), with new
fixtures in the big_pipe_test module), mirroring the file's existing
testMultipleOccurrences() test.
Live-verified via Playwright on a real Commerce add-to-cart form resolved through a BigPipe
placeholder, on a page with 11 total placeholders (i.e. bug 2's clobber scenario present): after the
patch, data-once sets to "drupal-ajax", drupalSettings.ajax
retains its key through the full pipeline, and a real click fires real AJAX requests. A separate page
with 26 similar forms (already working before this patch) was spot-checked with the patch applied: no
regression, 26/26 still bind correctly.
Remaining tasks
- Maintainer/issue-queue review of the fix approach - two alternative directions exist for bug 1
(reorder command emission server-side vs. this patch's client-side re-trigger-after-swap); this patch
takes the re-trigger approach as less invasive and scoped entirely toinsert(), but a
core committer's read on intended architecture may prefer reordering emission instead. - The attached regression test has been exercised via live browser testing (Playwright, against a
real site) but not yet run through Drupal core's own PHPUnit/WebDriver test runner - needs a run
before commit. - Cross-check against #3565273
(Olivero/Gin nav dropdowns not re-attaching after BigPipe) - possibly the same class of defect (bug
1, generically, not #ajax-specific), worth a comment there once this is triaged.
User interface changes
None. This is a client-side JavaScript bug fix with no visible UI or markup changes; the fix
restores existing, already-documented `#ajax` behavior rather than adding anything new.
Introduced terminology
None.
API changes
None public. Drupal.htmx.mergeSettings()'s internal merge behavior changes (an empty
incoming array no longer overwrites an existing non-empty value at the same key), but this is
undocumented internal BigPipe/htmx plumbing, not a public JS API, and the new behavior is strictly
more correct than the old (data preservation instead of silent loss).
Data model changes
None.
| Comment | File | Size | Author |
|---|---|---|---|
| bigpipe-htmx-ajax-command-ordering.patch | 9.18 KB | aurora.luzzardi |
Issue fork drupal-3620565
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
quietone commentedIssues are fixed on the main branch and then backported according to our policies.,
Updating tags per the issue tag guidelines. Updating title per our guidelines.
Comment #3
cilefen commentedComment #6
aurora.luzzardiComment #7
smustgrave commentedThis is reading like heavy AI
but I don't see any disclosure of it..my mistake I see it. But still this is a lot of AI output that doubt is all needed and could be written in human..Comment #8
quietone commented@aurora.luzzardi, thanks for helping Drupal core. It would help us if the issue summary was in your own words as if explaining the problem to another person. It should help to read this section of the AI policy, particularly the text in the warning box. Thanks
Comment #9
smustgrave commentedThanks @quietone, actually putting back into NW for it to be re-written
Comment #10
aurora.luzzardiComment #11
aurora.luzzardiHi @smustgrave, @quietone,
Thanks for the feedback!
I added a simple summary to it, if you want I can remove the IA below but I think it help to explain better the problem, as I'm not an native English speaker, my attempt to describe might not be the best one.
Also on the MR, last time I run the Pipelines, the issue was not on the changes I did, was something directly from the Main, I can see if needs to receive an update.
Comment #12
aurora.luzzardi