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.

  1. 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.
  2. 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/commerce 3.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_pipe core module: enabled, no BigPipe-related contrib modules installed - 100%
    core JS

Steps to reproduce

  1. Enable big_pipe (enabled by default in most installs).
  2. 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).

  3. Load the page as an authenticated user with a session (BigPipe requires a session to engage its
    placeholder strategy for most content).
  4. 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 own settings command wiped
      it, even though the button's own settings command 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:

  1. insert() (core/modules/big_pipe/js/big_pipe.commands.js) re-queries its
    (now-swapped) selector targets after the DOM swap and re-triggers
    htmx:drupal:load on each, so behaviors attach to what's actually in the DOM rather than
    relying solely on add_js's earlier, too-early trigger. No-op for content the swap didn't
    touch, since Drupal's once() markers make re-attaching idempotent.
  2. 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's settings command.

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-chained reduce().
  • core/modules/big_pipe/js/big_pipe.commands.js:56-83 - the insert
    command; note the outerHTML/replaceWith swap style mapping and the
    pre-swap htmx.trigger(target, 'htmx:drupal:unload') on the old
    target.
  • core/modules/big_pipe/js/big_pipe.commands.js:110-114 - the settings
    command; calls Drupal.htmx.mergeSettings(drupalSettings, settings) when
    merge: true.
  • core/modules/big_pipe/js/big_pipe.commands.js:165-169 - the add_js
    command; fires htmx.trigger(document.body, 'htmx:drupal:load') after assets resolve,
    always before insert has run for the same placeholder.
  • core/misc/htmx/htmx-behaviors.js:14-18 - the htmx:drupal:load
    listener; calls Drupal.attachBehaviors(detail.elt, drupalSettings).
  • core/misc/htmx/htmx-behaviors.js:22-24 - the htmx:drupal:unload
    listener; calls Drupal.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 to insert(), 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.

Issue fork drupal-3620565

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

aurora.luzzardi created an issue. See original summary.

quietone’s picture

Title: BigPipe htmx pipeline unbinds #ajax behaviors (attach-before-swap ordering) and can wipe drupalSettings.ajax (empty-array merge clobber) » BigPipe unbinds #ajax behaviors and can wipe drupalSettings.ajax
Version: 11.4.x-dev » main
Issue tags: -BigPipe, -cache, -Ajax

Issues 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.

cilefen’s picture

Status: Active » Needs work
Issue tags: +Needs merge request

aurora.luzzardi changed the visibility of the branch 3620565-bigpipe-unbinds-ajax to hidden.

aurora.luzzardi’s picture

Status: Needs work » Needs review
Issue tags: -Needs merge request +Needs review
Related issues: +#3555916: Update to htmx 4
smustgrave’s picture

This 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..

quietone’s picture

@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

smustgrave’s picture

Status: Needs review » Needs work

Thanks @quietone, actually putting back into NW for it to be re-written

aurora.luzzardi’s picture

Issue summary: View changes
aurora.luzzardi’s picture

Hi @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.

aurora.luzzardi’s picture

Status: Needs work » Needs review