From 9a7dfaed20d56f4579de8434eb78f7690cc1cef4 Mon Sep 17 00:00:00 2001
From: "Shawn P. Duncan" <code@sd.shawnduncan.org>
Date: Fri, 25 Apr 2025 07:43:33 -0400
Subject: [PATCH 01/15] Add javascript processor for htmx assets

---
 core/.eslintrc.json         |   1 +
 core/core.libraries.yml     |  11 +++
 core/misc/htmx-assets.js    | 182 ++++++++++++++++++++++++++++++++++++
 core/misc/htmx-behaviors.js |  38 ++++++++
 4 files changed, 232 insertions(+)
 create mode 100644 core/misc/htmx-assets.js
 create mode 100644 core/misc/htmx-behaviors.js

diff --git a/core/.eslintrc.json b/core/.eslintrc.json
index 6a6916b1a2c0..7b1ed46abeca 100644
--- a/core/.eslintrc.json
+++ b/core/.eslintrc.json
@@ -21,6 +21,7 @@
     "_": true,
     "Cookies": true,
     "Backbone": true,
+    "htmx": true,
     "loadjs": true,
     "Shepherd": true,
     "Sortable": true,
diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 5ec851e898e7..24600c25a26b 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -614,6 +614,17 @@ drupal.form:
     - core/drupal.debounce
     - core/once
 
+drupal.htmx:
+  version: VERSION
+  js:
+    misc/htmx-assets.js: {}
+    misc/htmx-behaviors.js: {}
+  dependencies:
+    - core/drupal
+    - core/drupalSettings
+    - core/htmx
+    - core/loadjs
+
 drupal.machine-name:
   version: VERSION
   js:
diff --git a/core/misc/htmx-assets.js b/core/misc/htmx-assets.js
new file mode 100644
index 000000000000..5829a4a682fb
--- /dev/null
+++ b/core/misc/htmx-assets.js
@@ -0,0 +1,182 @@
+/**
+ * @file
+ * Adds needed assets to the current page.
+ */
+
+// JavaScript should be made compatible with libraries other than jQuery by
+// wrapping it in an anonymous closure.
+(function (Drupal, drupalSettings, once, loadjs, htmx) {
+  /**
+   * Helper function to merge two objects recursively.
+   *
+   * @param current
+   *   The object to receive the merged values.
+   * @param sources
+   *   The objects to merge into current.
+   *
+   * @return object
+   *   The merged object.
+   *
+   * @see https://youmightnotneedjquery.com/#deep_extend
+   */
+  function mergeSettings(current, ...sources) {
+    if (!current) {
+      return {};
+    }
+
+    sources
+      .filter((obj) => Boolean(obj))
+      .forEach((obj) => {
+        Object.entries(obj).forEach(([key, value]) => {
+          switch (Object.prototype.toString.call(value)) {
+            case '[object Object]':
+              current[key] = current[key] || {};
+              current[key] = mergeSettings(current[key], value);
+              break;
+
+            case '[object Array]':
+              current[key] = mergeSettings(new Array(value.length), value);
+              break;
+
+            default:
+              current[key] = value;
+          }
+        });
+      });
+
+    return current;
+  }
+
+  /**
+   * Function to process and send the current ajax page state with each request.
+   *
+   * @param configRequestEvent
+   *   HTMX event for request configuration.
+   */
+  function htmxDrupalData(configRequestEvent) {
+    const url = new URL(configRequestEvent.detail.path, document.location.href);
+    const origin = document.location.origin;
+    const sameHost = origin === url.origin;
+    if (sameHost) {
+      // We only need to add this data for htmx requests back to the site.
+      configRequestEvent.detail.headers['HX-Page-State'] =
+        drupalSettings.ajaxPageState.libraries;
+      // HTMX sets these header values using #id values.
+      // Swap in drupal data selectors as #id values are altered to be unique.
+      configRequestEvent.detail.headers['HX-Target'] =
+        configRequestEvent.detail.target.dataset.drupalSelector;
+      configRequestEvent.detail.headers['HX-Trigger'] =
+        configRequestEvent.detail.elt.dataset.drupalSelector;
+    }
+  }
+
+  /**
+   * Function to obtain, process, and attach new assets.
+   *
+   * @param {event} oobSwapEvent
+   *   The event fired by HTMX.
+   */
+  function htmxDrupalAssetProcessor(oobSwapEvent) {
+    // Find any inserted assets.
+    const assetsTag = oobSwapEvent.detail.target.querySelector(
+      'script[data-drupal-selector="drupal-htmx-assets"]',
+    );
+    if (!(assetsTag instanceof HTMLElement)) {
+      return;
+    }
+    // Parse assets and initialize container variables.
+    const assets = JSON.parse(assetsTag.textContent);
+    let cssItems = [];
+    let scriptItems = [];
+    let scriptBottomItems = [];
+    let paths = [];
+    if (assets.constructor.name === 'Object') {
+      // Variable assets should have properties 'styles', 'scripts,
+      // 'scripts_bottom', 'settings'. See
+      // HtmxResponseAttachmentsProcessor::processAssetLibraries
+      cssItems = new Map(
+        assets.styles.map((item) => [`css!${item['#attributes'].href}`, item]),
+      );
+      scriptItems = new Map(
+        assets.scripts.map((item) => [item['#attributes'].src, item]),
+      );
+      scriptBottomItems = new Map(
+        assets.scripts_bottom.map((item) => [item['#attributes'].src, item]),
+      );
+      mergeSettings(drupalSettings, assets.settings);
+    }
+    // Load CSS.
+    if (cssItems instanceof Map && cssItems.size > 0) {
+      // Fetch in parallel but load in sequence.
+      cssItems.forEach((value, key) => paths.push(key));
+      loadjs(paths, {
+        async: false,
+        before(path, linkElement) {
+          const item = cssItems.get(path);
+          Object.entries(item['#attributes']).forEach(
+            ([attributeKey, attributeValue]) => {
+              linkElement.setAttribute(attributeKey, attributeValue);
+            },
+          );
+        },
+      });
+    }
+    /* By default, loadjs appends the script to the head. When scripts
+     * are loaded via HTMX, their location has no impact on
+     * functionality. But, since Drupal loaded scripts can choose
+     * their parent element, we provide that option here for the sake of
+     * consistency.
+     */
+    if (scriptItems instanceof Map && scriptItems.size > 0) {
+      paths = [];
+      scriptItems.forEach((value, key) => paths.push(key));
+      // Load head JS.
+      // Fetch in parallel but load in sequence.
+      loadjs(paths, {
+        async: false,
+        before(path, scriptElement) {
+          const item = scriptItems.get(path);
+          Object.entries(item['#attributes']).forEach(
+            ([attributeKey, attributeValue]) => {
+              scriptElement.setAttribute(attributeKey, attributeValue);
+            },
+          );
+        },
+      });
+    }
+    if (scriptBottomItems instanceof Map && scriptBottomItems.size > 0) {
+      paths = [];
+      scriptBottomItems.forEach((value, key) => paths.push(key));
+      // Fetch in parallel but load in sequence.
+      loadjs(paths, {
+        async: false,
+        before(path, scriptElement) {
+          const item = scriptBottomItems.get(path);
+          Object.entries(item['#attributes']).forEach(
+            ([attributeKey, attributeValue]) => {
+              scriptElement.setAttribute(attributeKey, attributeValue);
+            },
+          );
+          document.body.appendChild(scriptElement);
+          // Return false to bypass loadjs' default DOM insertion
+          // mechanism.
+          return false;
+        },
+      });
+    }
+    // Any assets are now processed.  Remove the found asset tag.
+    htmx.remove(assetsTag);
+  }
+
+  /* Initialize listeners. */
+
+  Drupal.behaviors.htmxAssets = {
+    attach: () => {
+      if (!once('htmxAssets', 'html').length) {
+        return;
+      }
+      window.addEventListener('htmx:configRequest', htmxDrupalData);
+      window.addEventListener('htmx:oobAfterSwap', htmxDrupalAssetProcessor);
+    },
+  };
+})(Drupal, drupalSettings, once, loadjs, htmx);
diff --git a/core/misc/htmx-behaviors.js b/core/misc/htmx-behaviors.js
new file mode 100644
index 000000000000..3da0288b3e60
--- /dev/null
+++ b/core/misc/htmx-behaviors.js
@@ -0,0 +1,38 @@
+/**
+ * @file
+ * Connect Drupal.behaviors to htmx inserted content.
+ */
+(function (Drupal, once, htmx, drupalSettings) {
+  function htmxDrupalBehaviors(htmxLoadEvent) {
+    // The attachBehaviors method searches within the context.
+    // We need to go up one level so that the loaded content is processed
+    // completely.
+    Drupal.attachBehaviors(
+      htmxLoadEvent.detail.elt?.parentElement,
+      drupalSettings,
+    );
+  }
+
+  /* Initialize listeners. */
+
+  Drupal.behaviors.htmxBehaviors = {
+    attach: () => {
+      if (!once('htmxBehaviors', 'html').length) {
+        return;
+      }
+      window.addEventListener('htmx:load', htmxDrupalBehaviors);
+      window.addEventListener('htmx:oobAfterSwap', htmxDrupalBehaviors);
+    },
+  };
+
+  /**
+   * Also send new markup through htmx processing.
+   */
+  Drupal.behaviors.htmxProcess = {
+    attach(context) {
+      if (typeof htmx === 'object' && htmx.hasOwnProperty('process')) {
+        htmx.process(context);
+      }
+    },
+  };
+})(Drupal, once, htmx, drupalSettings);
-- 
GitLab


From 56e23719dce85a30d408cd477be5aafb1a18622b Mon Sep 17 00:00:00 2001
From: Shawn Duncan <duncans3@mskcc.org>
Date: Fri, 25 Apr 2025 12:00:53 -0400
Subject: [PATCH 02/15] Add backend processing for assets on HTMX requests.

---
 .../HtmxResponseSubscriber.php                |  58 ++++++++++
 .../HtmxResponseAttachmentsProcessor.php      | 108 ++++++++++++++++++
 2 files changed, 166 insertions(+)
 create mode 100644 core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php
 create mode 100644 core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php

diff --git a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php
new file mode 100644
index 000000000000..46768cac81ad
--- /dev/null
+++ b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\EventSubscriber;
+
+use Drupal\Core\Render\AttachmentsResponseProcessorInterface;
+use Drupal\Core\Render\HtmlResponse;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpKernel\Event\ResponseEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+
+/**
+ * Adds assets on responses to HTMX requests.
+ *
+ * @see \Drupal\Core\Ajax\HtmxResponseAttachmentsProcessor
+ */
+final class HtmxResponseSubscriber implements EventSubscriberInterface {
+
+  /**
+   * Constructs a HtmxResponseSubscriber object.
+   */
+  public function __construct(
+    private readonly RequestStack $requestStack,
+    private readonly AttachmentsResponseProcessorInterface $attachmentsProcessor,
+  ) {}
+
+  /**
+   * Add assemble and attachments add HTMX attributes.
+   *
+   * @see \Drupal\Core\EventSubscriber\HtmlResponseSubscriber::onRespond
+   */
+  public function onRespond(ResponseEvent $event): void {
+    $response = $event->getResponse();
+    $requestHeaders = $this->requestStack->getCurrentRequest()->headers;
+    if (!($response instanceof HtmlResponse && $requestHeaders->has('HX-Request'))) {
+      // Only operate on HTML responses from an HTMX request.
+      return;
+    }
+    $processedResponse = $this->attachmentsProcessor->processAttachments($response);
+    if (!($processedResponse instanceof HtmlResponse)) {
+      throw new \TypeError("ResponseEvent::setResponse() requires an HtmlResponse object");
+    }
+    $event->setResponse($processedResponse);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents(): array {
+    return [
+      // Our method needs to run before the HtmlResponseSubscriber(weight 0).
+      KernelEvents::RESPONSE => ['onRespond', 100],
+    ];
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
new file mode 100644
index 000000000000..fedeeba6871e
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
@@ -0,0 +1,108 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Render\Hypermedia;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\Asset\AttachedAssetsInterface;
+use Drupal\Core\Render\AttachmentsInterface;
+use Drupal\Core\Render\HtmlResponse;
+use Drupal\Core\Render\HtmlResponseAttachmentsProcessor;
+
+/**
+ * Prepares attachment for HTMX powered responses.
+ *
+ * Extends the HTML response processor encode attachment data.
+ *
+ * @see \Drupal\Core\EventSubscriber\HtmxResponseSubscriber
+ * @see \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::processAttachments
+ * @see core/misc/htmx-behaviors.js
+ */
+class HtmxResponseAttachmentsProcessor extends HtmlResponseAttachmentsProcessor {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function processAttachments(AttachmentsInterface $response): HtmlResponse {
+    $processed = parent::processAttachments($response);
+    if (!($processed instanceof HtmlResponse)) {
+      // Something has gone wrong. We sent an HtmlResponse that also
+      // implemented AttachmentInterface and should have received the same back.
+      throw new \TypeError("HtmlResponseAttachmentsProcessor::processAttachments should return an HtmlResponse.");
+    }
+    return $processed;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function processAssetLibraries(AttachedAssetsInterface $assets, array $placeholders) {
+    $request = $this->requestStack->getCurrentRequest();
+    if ($request->headers->has('HX-Page-State')) {
+      $uncompressed = UrlHelper::uncompressQueryParameter($request->headers->get('HX-Page-State'));
+      $libraries = explode(',', $uncompressed);
+      $assets->setAlreadyLoadedLibraries($libraries);
+    }
+    $settings = [];
+    $variables = [];
+    $maintenance_mode = defined('MAINTENANCE_MODE') || \Drupal::state()->get('system.maintenance_mode');
+
+    // Print styles - if present.
+    if (isset($placeholders['styles'])) {
+      // Optimize CSS if necessary, but only during normal site operation.
+      $optimize_css = !$maintenance_mode && $this->config->get('css.preprocess');
+      $variables['styles'] = $this->cssCollectionRenderer->render($this->assetResolver->getCssAssets($assets, $optimize_css, $this->languageManager->getCurrentLanguage()));
+    }
+
+    // Copy and adjust parent::processAssetLibraries to adjust and
+    // remove drupalSettings in line with
+    // AjaxResponseAttachmentsProcessor::buildAttachmentsCommands
+    // Print scripts - if any are present.
+    if (isset($placeholders['scripts']) || isset($placeholders['scripts_bottom'])) {
+      // Optimize JS if necessary, but only during normal site operation.
+      $optimize_js = !$maintenance_mode && $this->config->get('js.preprocess');
+      [$js_assets_header, $js_assets_footer] = $this->assetResolver
+        ->getJsAssets($assets, $optimize_js, $this->languageManager->getCurrentLanguage());
+      if (isset($js_assets_header['drupalSettings'])) {
+        $settings = $js_assets_header['drupalSettings']['data'];
+        unset($js_assets_header['drupalSettings']);
+      }
+      if (isset($js_assets_footer['drupalSettings'])) {
+        $settings = $js_assets_footer['drupalSettings']['data'];
+        unset($js_assets_footer['drupalSettings']);
+      }
+      if (empty($settings)) {
+        $settings = $assets->getSettings();
+      }
+      $variables['scripts'] = $this->jsCollectionRenderer->render($js_assets_header);
+      $variables['scripts_bottom'] = $this->jsCollectionRenderer->render($js_assets_footer);
+      unset($settings['path']);
+      $variables['settings'] = $settings;
+    }
+    /*
+     * @todo Update to use HtmxAttribute::swapOob('beforeend:body')
+     */
+    return [
+      'styles' => [],
+      'scripts' => [],
+      'scripts_bottom' => [
+        '#type' => 'container',
+        '#attributes' => [
+          'data-hx-swap-oob' => 'beforeend:body'
+        ],
+        'libraries' => [
+          '#type' => 'html_tag',
+          '#tag' => 'script',
+          '#attributes' => [
+            'type' => 'application/json',
+            'data-drupal-selector' => 'drupal-htmx-assets',
+          ],
+          '#value' => Json::encode($variables),
+        ],
+      ],
+    ];
+  }
+
+}
-- 
GitLab


From a763c62dbb84196e0de8fef74f1f2836fa3f084b Mon Sep 17 00:00:00 2001
From: "Shawn P. Duncan" <code@sd.shawnduncan.org>
Date: Sat, 26 Apr 2025 08:06:19 -0400
Subject: [PATCH 03/15] missing dependency

---
 core/core.libraries.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 24600c25a26b..19be4912dee6 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -624,6 +624,7 @@ drupal.htmx:
     - core/drupalSettings
     - core/htmx
     - core/loadjs
+    - core/once
 
 drupal.machine-name:
   version: VERSION
-- 
GitLab


From e75f82491a0280254a3511700ed12b4814c58268 Mon Sep 17 00:00:00 2001
From: "Shawn P. Duncan" <code@sd.shawnduncan.org>
Date: Sat, 26 Apr 2025 09:22:16 -0400
Subject: [PATCH 04/15] Add services to core.services.yml

---
 core/core.services.yml | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/core/core.services.yml b/core/core.services.yml
index 4372e6512c3e..c27b5cba167c 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -1400,6 +1400,14 @@ services:
   html_response.subscriber:
     class: Drupal\Core\EventSubscriber\HtmlResponseSubscriber
     arguments: ['@html_response.attachments_processor']
+  htmx_response.attachments_processor:
+    class: Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor
+    parent: html_response.attachments_processor
+  htmx.response_subscriber:
+    class: Drupal\Core\EventSubscriber\HtmxResponseSubscriber
+    arguments: [ '@request_stack', '@htmx_response.attachments_processor' ]
+    tags:
+      - { name: event_subscriber }
   finish_response_subscriber:
     class: Drupal\Core\EventSubscriber\FinishResponseSubscriber
     arguments: ['@language_manager', '@config.factory', '@page_cache_request_policy', '@page_cache_response_policy', '@cache_contexts_manager', '@datetime.time', '%http.response.debug_cacheability_headers%']
-- 
GitLab


From c693567cfd8da5352ecc56951c89bb16630cef44 Mon Sep 17 00:00:00 2001
From: Shawn Duncan <duncans3@mskcc.org>
Date: Tue, 29 Apr 2025 13:40:53 -0400
Subject: [PATCH 05/15] Add automated tests

---
 .../test_htmx/js/reveal-merged-settings.js    | 27 +++++++
 .../HtmxTestAttachmentsController.php         | 79 +++++++++++++++++++
 .../modules/test_htmx/test_htmx.info.yml      |  5 ++
 .../modules/test_htmx/test_htmx.libraries.yml |  7 ++
 .../modules/test_htmx/test_htmx.routing.yml   | 17 ++++
 .../Tests/htmx/htmxAssetLoadTest.js           | 44 +++++++++++
 .../TestSite/HtmxAssetLoadTestSetup.php       | 45 +++++++++++
 7 files changed, 224 insertions(+)
 create mode 100644 core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js
 create mode 100644 core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
 create mode 100644 core/modules/system/tests/modules/test_htmx/test_htmx.info.yml
 create mode 100644 core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml
 create mode 100644 core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml
 create mode 100644 core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
 create mode 100644 core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php

diff --git a/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js b/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js
new file mode 100644
index 000000000000..2018775c3267
--- /dev/null
+++ b/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js
@@ -0,0 +1,27 @@
+/**
+ * @file
+ * Display merged value from drupalSettings for testing.
+ */
+(function (Drupal) {
+  /**
+   * Tests that settings were merged.
+   * Tests that behaviors are attached to htmx inserted content.
+   *
+   * @see \Drupal\htmx_test\Controller\HtmxTestAttachmentsController::replace
+   */
+  Drupal.behaviors.revealMerged_settings = {
+    attach(context, settings) {
+      const dropButtonContainer = context.querySelector(
+        'div.dropbutton-wrapper',
+      );
+      if (
+        dropButtonContainer instanceof HTMLElement &&
+        Object.hasOwn(settings, 'htmxTest')
+      ) {
+        dropButtonContainer.append(
+          `Settings found: ${drupalSettings.htmxTest}`,
+        );
+      }
+    },
+  };
+})(Drupal);
diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
new file mode 100644
index 000000000000..8f53aa480cc2
--- /dev/null
+++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\test_htmx\Controller;
+
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Url;
+use Drupal\htmx\Template\HtmxAttribute;
+
+/**
+ * Returns responses for HTMX Test Attachments routes.
+ */
+final class HtmxTestAttachmentsController extends ControllerBase {
+
+  /**
+   * Builds the response.
+   *
+   * @return mixed[]
+   *   A render array.
+   */
+  public function page(): array {
+//    $htmx = new HtmxAttribute(['class' => ['button'], 'name' => 'replace']);
+//    $htmx->get(Url::fromRoute('htmx_test_attachments.replace'))
+//      ->select('div.dropbutton-wrapper')
+//      ->swap('afterend');
+    $url = Url::fromRoute('test_htmx.attachments.replace');
+    $build['content'] = [
+      '#type' => 'container',
+      '#attached' => [
+        'library' => [
+          'core/drupal.htmx',
+          'test_htmx/attachments',
+        ],
+      ],
+      '#attributes' => [
+        'class' => ['htmx-test-container'],
+      ],
+      'button' => [
+        '#type' => 'inline_template',
+        '#template' => '<button class="button" name="replace" data-hx-get="{{- url -}}" data-hx-select="div.dropbutton-wrapper" data-hx-swap="afterend">Click this</button>',
+        '#context' => [
+          'url' => $url,
+        ],
+      ],
+    ];
+
+    return $build;
+  }
+
+  /**
+   * Builds the HTMX response.
+   *
+   * @return mixed[]
+   *   A render array.
+   */
+  public function replace(): array {
+    $build['content'] = [
+      '#type' => 'dropbutton',
+      '#dropbutton_type' => 'small',
+      '#links' => [
+        'simple_form' => [
+          'title' => $this->t('Sample link 1'),
+          'url' => Url::fromRoute('system.timezone'),
+        ],
+        'demo' => [
+          'title' => $this->t('Sample link 2'),
+          'url' => Url::fromRoute('<front>'),
+        ],
+      ],
+      '#attached' => [
+        'drupalSettings' => ['htmxTest' => 'success'],
+      ],
+    ];
+
+    return $build;
+  }
+
+}
diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml
new file mode 100644
index 000000000000..754f482385dc
--- /dev/null
+++ b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml
@@ -0,0 +1,5 @@
+name: 'HTMX Test Fixtures'
+type: module
+description: 'Test fixtures for HTMX integration'
+package: Test
+core_version_requirement: ^11
diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml
new file mode 100644
index 000000000000..b5cdc377af61
--- /dev/null
+++ b/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml
@@ -0,0 +1,7 @@
+attachments:
+  version: VERSION
+  js:
+    js/reveal-merged-settings.js: {}
+  dependencies:
+    - core/drupal
+    - core/drupalSettings
diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml
new file mode 100644
index 000000000000..b6a82293bffc
--- /dev/null
+++ b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml
@@ -0,0 +1,17 @@
+test_htmx.attachments.page:
+  path: '/htmx-test-attachments/page'
+  defaults:
+    _title: 'Page'
+    _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::page'
+  requirements:
+    _permission: 'access content'
+
+test_htmx.attachments.replace:
+  path: '/htmx-test-attachments/replace'
+  defaults:
+    _title: 'Dropbutton'
+    _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::replace'
+  requirements:
+    _permission: 'access content'
+  options:
+    _htmx_route: 'true'
diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
new file mode 100644
index 000000000000..1f17416694e4
--- /dev/null
+++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
@@ -0,0 +1,44 @@
+// The javascript that creates dropbuttons is not present on the /page at
+// initial load.  If the once data property is added then the JS was loaded
+// and triggered on the inserted content.
+// @see \Drupal\test_htmx\Controller\HtmxTestAttachmentsController
+// @see core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js
+module.exports = {
+  '@tags': ['core', 'htmx'],
+  before(browser) {
+    browser.drupalInstall({
+      setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php',
+    });
+  },
+  afterEach(browser) {
+    browser.drupalLogAndEnd({ onlyOnError: true });
+  },
+  after(browser) {
+    browser.drupalUninstall();
+  },
+
+  'Asset Load': (browser) => {
+    // Load the route htmx will request and confirm the markup we will be looking
+    // for is present in the source markup.
+    browser
+      .drupalRelativeURL('/htmx-test-attachments/replace')
+      .waitForElementVisible('body', 1000)
+      .assert.elementPresent('script[src*="dropbutton.js"]');
+    // Now load the page with the htmx enhanced button and verify the absence
+    // of the markup to be inserted. Click the button
+    // and check for inserted javascript, markup, and output that verifies
+    // merged settings.
+    browser
+      .drupalRelativeURL('/htmx-test-attachments/page')
+      .waitForElementVisible('body', 1000)
+      .assert.not.elementPresent('script[src*="dropbutton.js"]')
+      .waitForElementVisible('button[name="replace"]', 1000)
+      .pause(1000)
+      .click('button[name="replace"]')
+      .waitForElementVisible('div.dropbutton-wrapper', 60000)
+      .waitForElementVisible('div[data-once="dropbutton"]', 60000)
+      .assert.elementPresent('script[src*="dropbutton.js"]')
+      .pause(1000)
+      .assert.textContains('div.dropbutton-wrapper', 'Settings found: success');
+  },
+};
diff --git a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php
new file mode 100644
index 000000000000..1746cf3d948b
--- /dev/null
+++ b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\TestSite;
+
+use Drupal\Core\Extension\ModuleInstallerInterface;
+use Drupal\Core\Extension\ThemeInstallerInterface;
+use Drupal\TestSite\TestSetupInterface;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+
+/**
+ * Setup file used by tests/src/Nightwatch/Tests/htmxAssetLoadTest.js.
+ *
+ * @see \Drupal\Tests\Scripts\TestSiteApplicationTest
+ */
+class HtmxAssetLoadTestSetup implements TestSetupInterface {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setup() {
+    // Install Olivero and set it as the default theme.
+    $theme_installer = \Drupal::service('theme_installer');
+    assert($theme_installer instanceof ThemeInstallerInterface);
+    $theme_installer->install(['olivero'], TRUE);
+    $system_theme_config = \Drupal::configFactory()->getEditable('system.theme');
+    $system_theme_config->set('default', 'olivero')->save();
+
+    // Install required modules.
+    $module_installer = \Drupal::service('module_installer');
+    assert($module_installer instanceof ModuleInstallerInterface);
+    $module_installer->install(['system']);
+    $module_installer->install(['test_htmx']);
+
+    // Insure correct permissions.
+    $this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), ['access content']);
+
+  }
+
+}
-- 
GitLab


From e0fe8c59611363bda989f1c5cc7043d3776cba7b Mon Sep 17 00:00:00 2001
From: Shawn Duncan <duncans3@mskcc.org>
Date: Tue, 29 Apr 2025 13:58:35 -0400
Subject: [PATCH 06/15] phpcs lint

---
 .../Render/Hypermedia/HtmxResponseAttachmentsProcessor.php   | 4 ++--
 .../src/Controller/HtmxTestAttachmentsController.php         | 5 -----
 core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php        | 1 -
 3 files changed, 2 insertions(+), 8 deletions(-)

diff --git a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
index fedeeba6871e..f81ad577f1be 100644
--- a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
+++ b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
@@ -82,7 +82,7 @@ protected function processAssetLibraries(AttachedAssetsInterface $assets, array
       $variables['settings'] = $settings;
     }
     /*
-     * @todo Update to use HtmxAttribute::swapOob('beforeend:body')
+     * @todo Update to use `#htmx` render array property when available.
      */
     return [
       'styles' => [],
@@ -90,7 +90,7 @@ protected function processAssetLibraries(AttachedAssetsInterface $assets, array
       'scripts_bottom' => [
         '#type' => 'container',
         '#attributes' => [
-          'data-hx-swap-oob' => 'beforeend:body'
+          'data-hx-swap-oob' => 'beforeend:body',
         ],
         'libraries' => [
           '#type' => 'html_tag',
diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
index 8f53aa480cc2..e72d92239ef8 100644
--- a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
+++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
@@ -6,7 +6,6 @@
 
 use Drupal\Core\Controller\ControllerBase;
 use Drupal\Core\Url;
-use Drupal\htmx\Template\HtmxAttribute;
 
 /**
  * Returns responses for HTMX Test Attachments routes.
@@ -20,10 +19,6 @@ final class HtmxTestAttachmentsController extends ControllerBase {
    *   A render array.
    */
   public function page(): array {
-//    $htmx = new HtmxAttribute(['class' => ['button'], 'name' => 'replace']);
-//    $htmx->get(Url::fromRoute('htmx_test_attachments.replace'))
-//      ->select('div.dropbutton-wrapper')
-//      ->swap('afterend');
     $url = Url::fromRoute('test_htmx.attachments.replace');
     $build['content'] = [
       '#type' => 'container',
diff --git a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php
index 1746cf3d948b..23a31f40f7c3 100644
--- a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php
+++ b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php
@@ -6,7 +6,6 @@
 
 use Drupal\Core\Extension\ModuleInstallerInterface;
 use Drupal\Core\Extension\ThemeInstallerInterface;
-use Drupal\TestSite\TestSetupInterface;
 use Drupal\Tests\user\Traits\UserCreationTrait;
 use Drupal\user\Entity\Role;
 use Drupal\user\RoleInterface;
-- 
GitLab


From c5ebe71ae82aba090dc62e470fa4a87b294d69bd Mon Sep 17 00:00:00 2001
From: Shawn Duncan <duncans3@mskcc.org>
Date: Tue, 29 Apr 2025 14:46:46 -0400
Subject: [PATCH 07/15] phpstan lint

---
 .../Nightwatch/Tests/htmx/htmxAssetLoadTest.js       |  8 +++++---
 .../tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php | 12 +-----------
 2 files changed, 6 insertions(+), 14 deletions(-)

diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
index 1f17416694e4..5892cdba046e 100644
--- a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
+++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
@@ -6,9 +6,11 @@
 module.exports = {
   '@tags': ['core', 'htmx'],
   before(browser) {
-    browser.drupalInstall({
-      setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php',
-    });
+    browser
+      .drupalInstall({
+        setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php',
+        installProfile: 'minimal',
+      });
   },
   afterEach(browser) {
     browser.drupalLogAndEnd({ onlyOnError: true });
diff --git a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php
index 23a31f40f7c3..03bb3fbdb113 100644
--- a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php
+++ b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php
@@ -6,9 +6,6 @@
 
 use Drupal\Core\Extension\ModuleInstallerInterface;
 use Drupal\Core\Extension\ThemeInstallerInterface;
-use Drupal\Tests\user\Traits\UserCreationTrait;
-use Drupal\user\Entity\Role;
-use Drupal\user\RoleInterface;
 
 /**
  * Setup file used by tests/src/Nightwatch/Tests/htmxAssetLoadTest.js.
@@ -17,12 +14,10 @@
  */
 class HtmxAssetLoadTestSetup implements TestSetupInterface {
 
-  use UserCreationTrait;
-
   /**
    * {@inheritdoc}
    */
-  public function setup() {
+  public function setup(): void {
     // Install Olivero and set it as the default theme.
     $theme_installer = \Drupal::service('theme_installer');
     assert($theme_installer instanceof ThemeInstallerInterface);
@@ -33,12 +28,7 @@ public function setup() {
     // Install required modules.
     $module_installer = \Drupal::service('module_installer');
     assert($module_installer instanceof ModuleInstallerInterface);
-    $module_installer->install(['system']);
     $module_installer->install(['test_htmx']);
-
-    // Insure correct permissions.
-    $this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), ['access content']);
-
   }
 
 }
-- 
GitLab


From 3140982197c0c7fd855ae3cf92343cdfae9fbd23 Mon Sep 17 00:00:00 2001
From: Shawn Duncan <duncans3@mskcc.org>
Date: Tue, 29 Apr 2025 14:56:48 -0400
Subject: [PATCH 08/15] js lint

---
 .../Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js    | 9 ++++-----
 1 file changed, 4 insertions(+), 5 deletions(-)

diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
index 5892cdba046e..a933062d1808 100644
--- a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
+++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
@@ -6,11 +6,10 @@
 module.exports = {
   '@tags': ['core', 'htmx'],
   before(browser) {
-    browser
-      .drupalInstall({
-        setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php',
-        installProfile: 'minimal',
-      });
+    browser.drupalInstall({
+      setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php',
+      installProfile: 'minimal',
+    });
   },
   afterEach(browser) {
     browser.drupalLogAndEnd({ onlyOnError: true });
-- 
GitLab


From aa584191c091cb60cb1d003bf44effa055461915 Mon Sep 17 00:00:00 2001
From: "Shawn P. Duncan" <code@sd.shawnduncan.org>
Date: Wed, 30 Apr 2025 06:30:36 -0400
Subject: [PATCH 09/15] simplify nightwatch test for asset integration.

---
 .../test_htmx/js/reveal-merged-settings.js    | 27 -------------------
 .../HtmxTestAttachmentsController.php         |  4 ---
 .../modules/test_htmx/test_htmx.libraries.yml |  7 -----
 .../Tests/htmx/htmxAssetLoadTest.js           |  5 +---
 4 files changed, 1 insertion(+), 42 deletions(-)
 delete mode 100644 core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js
 delete mode 100644 core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml

diff --git a/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js b/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js
deleted file mode 100644
index 2018775c3267..000000000000
--- a/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * @file
- * Display merged value from drupalSettings for testing.
- */
-(function (Drupal) {
-  /**
-   * Tests that settings were merged.
-   * Tests that behaviors are attached to htmx inserted content.
-   *
-   * @see \Drupal\htmx_test\Controller\HtmxTestAttachmentsController::replace
-   */
-  Drupal.behaviors.revealMerged_settings = {
-    attach(context, settings) {
-      const dropButtonContainer = context.querySelector(
-        'div.dropbutton-wrapper',
-      );
-      if (
-        dropButtonContainer instanceof HTMLElement &&
-        Object.hasOwn(settings, 'htmxTest')
-      ) {
-        dropButtonContainer.append(
-          `Settings found: ${drupalSettings.htmxTest}`,
-        );
-      }
-    },
-  };
-})(Drupal);
diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
index e72d92239ef8..c90a7aec7dfd 100644
--- a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
+++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
@@ -25,7 +25,6 @@ public function page(): array {
       '#attached' => [
         'library' => [
           'core/drupal.htmx',
-          'test_htmx/attachments',
         ],
       ],
       '#attributes' => [
@@ -63,9 +62,6 @@ public function replace(): array {
           'url' => Url::fromRoute('<front>'),
         ],
       ],
-      '#attached' => [
-        'drupalSettings' => ['htmxTest' => 'success'],
-      ],
     ];
 
     return $build;
diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml
deleted file mode 100644
index b5cdc377af61..000000000000
--- a/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-attachments:
-  version: VERSION
-  js:
-    js/reveal-merged-settings.js: {}
-  dependencies:
-    - core/drupal
-    - core/drupalSettings
diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
index a933062d1808..55b409872394 100644
--- a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
+++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
@@ -27,8 +27,7 @@ module.exports = {
       .assert.elementPresent('script[src*="dropbutton.js"]');
     // Now load the page with the htmx enhanced button and verify the absence
     // of the markup to be inserted. Click the button
-    // and check for inserted javascript, markup, and output that verifies
-    // merged settings.
+    // and check for inserted javascript and markup.
     browser
       .drupalRelativeURL('/htmx-test-attachments/page')
       .waitForElementVisible('body', 1000)
@@ -39,7 +38,5 @@ module.exports = {
       .waitForElementVisible('div.dropbutton-wrapper', 60000)
       .waitForElementVisible('div[data-once="dropbutton"]', 60000)
       .assert.elementPresent('script[src*="dropbutton.js"]')
-      .pause(1000)
-      .assert.textContains('div.dropbutton-wrapper', 'Settings found: success');
   },
 };
-- 
GitLab


From 173572f42db08f99bea37e82df7bba3e03117352 Mon Sep 17 00:00:00 2001
From: "Shawn P. Duncan" <code@sd.shawnduncan.org>
Date: Wed, 30 Apr 2025 06:46:19 -0400
Subject: [PATCH 10/15] js lint - missing semicolon after simplifying.

---
 core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
index 55b409872394..ff75fe000cd2 100644
--- a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
+++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
@@ -37,6 +37,6 @@ module.exports = {
       .click('button[name="replace"]')
       .waitForElementVisible('div.dropbutton-wrapper', 60000)
       .waitForElementVisible('div[data-once="dropbutton"]', 60000)
-      .assert.elementPresent('script[src*="dropbutton.js"]')
+      .assert.elementPresent('script[src*="dropbutton.js"]');
   },
 };
-- 
GitLab


From 170b4796ecb3dc031f6ba97ba29a84e462847990 Mon Sep 17 00:00:00 2001
From: Shawn Duncan <duncans3@mskcc.org>
Date: Wed, 30 Apr 2025 15:41:16 -0400
Subject: [PATCH 11/15] Updates based on MR comments

---
 core/core.libraries.yml                       |  4 +-
 core/core.services.yml                        |  1 +
 .../HtmxResponseSubscriber.php                | 21 ++++-
 .../HtmxResponseAttachmentsProcessor.php      | 13 ---
 .../HtmxResponseAttachmentsProcessor.php      | 89 +++++++++++++++++++
 core/misc/{ => htmx}/htmx-assets.js           |  0
 core/misc/{ => htmx}/htmx-behaviors.js        | 18 +---
 .../modules/test_htmx/test_htmx.info.yml      |  2 +-
 8 files changed, 115 insertions(+), 33 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
 rename core/misc/{ => htmx}/htmx-assets.js (100%)
 rename core/misc/{ => htmx}/htmx-behaviors.js (58%)

diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 19be4912dee6..255fc911ec60 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -617,8 +617,8 @@ drupal.form:
 drupal.htmx:
   version: VERSION
   js:
-    misc/htmx-assets.js: {}
-    misc/htmx-behaviors.js: {}
+    misc/htmx/htmx-assets.js: {}
+    misc/htmx/htmx-behaviors.js: {}
   dependencies:
     - core/drupal
     - core/drupalSettings
diff --git a/core/core.services.yml b/core/core.services.yml
index c27b5cba167c..0a97bd5c1dfa 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -1403,6 +1403,7 @@ services:
   htmx_response.attachments_processor:
     class: Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor
     parent: html_response.attachments_processor
+    lazy: true
   htmx.response_subscriber:
     class: Drupal\Core\EventSubscriber\HtmxResponseSubscriber
     arguments: [ '@request_stack', '@htmx_response.attachments_processor' ]
diff --git a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php
index 46768cac81ad..857cdebdeb24 100644
--- a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php
+++ b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php
@@ -4,19 +4,21 @@
 
 namespace Drupal\Core\EventSubscriber;
 
+use Drupal\Component\Utility\Html;
 use Drupal\Core\Render\AttachmentsResponseProcessorInterface;
 use Drupal\Core\Render\HtmlResponse;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpKernel\Event\RequestEvent;
 use Symfony\Component\HttpKernel\Event\ResponseEvent;
 use Symfony\Component\HttpKernel\KernelEvents;
 
 /**
  * Adds assets on responses to HTMX requests.
  *
- * @see \Drupal\Core\Ajax\HtmxResponseAttachmentsProcessor
+ * @see \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor
  */
-final class HtmxResponseSubscriber implements EventSubscriberInterface {
+class HtmxResponseSubscriber implements EventSubscriberInterface {
 
   /**
    * Constructs a HtmxResponseSubscriber object.
@@ -45,6 +47,20 @@ public function onRespond(ResponseEvent $event): void {
     $event->setResponse($processedResponse);
   }
 
+  /**
+   * Sets \Drupal\Component\Utility\Html::$isAjax to TRUE.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
+   *   The request event, which contains the current request.
+   */
+  public function onRequest(RequestEvent $event) {
+    $requestHeaders = $event->getRequest()->headers;
+    // Pass to the Html class that the current request is an Ajax request.
+    if ($requestHeaders->has('HX-Request')) {
+      Html::setIsAjax(TRUE);
+    }
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -52,6 +68,7 @@ public static function getSubscribedEvents(): array {
     return [
       // Our method needs to run before the HtmlResponseSubscriber(weight 0).
       KernelEvents::RESPONSE => ['onRespond', 100],
+      KernelEvents::REQUEST => ['onRequest', 50],
     ];
   }
 
diff --git a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
index f81ad577f1be..8493c1c1b367 100644
--- a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
+++ b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
@@ -22,19 +22,6 @@
  */
 class HtmxResponseAttachmentsProcessor extends HtmlResponseAttachmentsProcessor {
 
-  /**
-   * {@inheritdoc}
-   */
-  public function processAttachments(AttachmentsInterface $response): HtmlResponse {
-    $processed = parent::processAttachments($response);
-    if (!($processed instanceof HtmlResponse)) {
-      // Something has gone wrong. We sent an HtmlResponse that also
-      // implemented AttachmentInterface and should have received the same back.
-      throw new \TypeError("HtmlResponseAttachmentsProcessor::processAttachments should return an HtmlResponse.");
-    }
-    return $processed;
-  }
-
   /**
    * {@inheritdoc}
    */
diff --git a/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
new file mode 100644
index 000000000000..b8a9cd3cc108
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
@@ -0,0 +1,89 @@
+<?php
+// phpcs:ignoreFile
+
+/**
+ * This file was generated via php core/scripts/generate-proxy-class.php 'Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor' "core/lib/Drupal/Core/Render/Hypermedia".
+ */
+
+namespace Drupal\Core\ProxyClass\Render\Hypermedia {
+
+    /**
+     * Provides a proxy class for \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor.
+     *
+     * @see \Drupal\Component\ProxyBuilder
+     */
+    class HtmxResponseAttachmentsProcessor implements \Drupal\Core\Render\AttachmentsResponseProcessorInterface
+    {
+
+        use \Drupal\Core\DependencyInjection\DependencySerializationTrait;
+
+        /**
+         * The id of the original proxied service.
+         *
+         * @var string
+         */
+        protected $drupalProxyOriginalServiceId;
+
+        /**
+         * The real proxied service, after it was lazy loaded.
+         *
+         * @var \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor
+         */
+        protected $service;
+
+        /**
+         * The service container.
+         *
+         * @var \Symfony\Component\DependencyInjection\ContainerInterface
+         */
+        protected $container;
+
+        /**
+         * Constructs a ProxyClass Drupal proxy object.
+         *
+         * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
+         *   The container.
+         * @param string $drupal_proxy_original_service_id
+         *   The service ID of the original service.
+         */
+        public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container, $drupal_proxy_original_service_id)
+        {
+            $this->container = $container;
+            $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id;
+        }
+
+        /**
+         * Lazy loads the real service from the container.
+         *
+         * @return object
+         *   Returns the constructed real service.
+         */
+        protected function lazyLoadItself()
+        {
+            if (!isset($this->service)) {
+                $this->service = $this->container->get($this->drupalProxyOriginalServiceId);
+            }
+
+            return $this->service;
+        }
+
+        /**
+         * {@inheritdoc}
+         */
+        public function processAttachments(\Drupal\Core\Render\AttachmentsInterface $response)
+        {
+            return $this->lazyLoadItself()->processAttachments($response);
+        }
+
+        /**
+         * {@inheritdoc}
+         */
+        public static function formatHttpHeaderAttributes(array $attributes = array (
+        ))
+        {
+            \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::formatHttpHeaderAttributes($attributes);
+        }
+
+    }
+
+}
diff --git a/core/misc/htmx-assets.js b/core/misc/htmx/htmx-assets.js
similarity index 100%
rename from core/misc/htmx-assets.js
rename to core/misc/htmx/htmx-assets.js
diff --git a/core/misc/htmx-behaviors.js b/core/misc/htmx/htmx-behaviors.js
similarity index 58%
rename from core/misc/htmx-behaviors.js
rename to core/misc/htmx/htmx-behaviors.js
index 3da0288b3e60..6986d4c79531 100644
--- a/core/misc/htmx-behaviors.js
+++ b/core/misc/htmx/htmx-behaviors.js
@@ -14,25 +14,13 @@
   }
 
   /* Initialize listeners. */
-
-  Drupal.behaviors.htmxBehaviors = {
-    attach: () => {
-      if (!once('htmxBehaviors', 'html').length) {
-        return;
-      }
-      window.addEventListener('htmx:load', htmxDrupalBehaviors);
-      window.addEventListener('htmx:oobAfterSwap', htmxDrupalBehaviors);
-    },
-  };
+  window.addEventListener('htmx:load', htmxDrupalBehaviors);
+  window.addEventListener('htmx:oobAfterSwap', htmxDrupalBehaviors);
 
   /**
    * Also send new markup through htmx processing.
    */
   Drupal.behaviors.htmxProcess = {
-    attach(context) {
-      if (typeof htmx === 'object' && htmx.hasOwnProperty('process')) {
-        htmx.process(context);
-      }
-    },
+    attach: htmx.process,
   };
 })(Drupal, once, htmx, drupalSettings);
diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml
index 754f482385dc..713f8a551a5b 100644
--- a/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml
+++ b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml
@@ -1,5 +1,5 @@
 name: 'HTMX Test Fixtures'
 type: module
 description: 'Test fixtures for HTMX integration'
-package: Test
+package: Testing
 core_version_requirement: ^11
-- 
GitLab


From e8eb11dff0b608e115fea14e4454d6608a410116 Mon Sep 17 00:00:00 2001
From: Shawn Duncan <duncans3@mskcc.org>
Date: Thu, 1 May 2025 14:19:21 -0400
Subject: [PATCH 12/15] Use a service closure

---
 core/core.services.yml                        |  3 +-
 .../HtmxResponseSubscriber.php                | 10 ++-
 .../HtmxResponseAttachmentsProcessor.php      | 89 -------------------
 3 files changed, 8 insertions(+), 94 deletions(-)
 delete mode 100644 core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php

diff --git a/core/core.services.yml b/core/core.services.yml
index 0a97bd5c1dfa..13e74fb5c003 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -1403,10 +1403,9 @@ services:
   htmx_response.attachments_processor:
     class: Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor
     parent: html_response.attachments_processor
-    lazy: true
   htmx.response_subscriber:
     class: Drupal\Core\EventSubscriber\HtmxResponseSubscriber
-    arguments: [ '@request_stack', '@htmx_response.attachments_processor' ]
+    arguments: [ '@request_stack', !service_closure Use '@htmx_response.attachments_processor' ]
     tags:
       - { name: event_subscriber }
   finish_response_subscriber:
diff --git a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php
index 857cdebdeb24..2ec01b9e5dff 100644
--- a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php
+++ b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php
@@ -24,8 +24,8 @@ class HtmxResponseSubscriber implements EventSubscriberInterface {
    * Constructs a HtmxResponseSubscriber object.
    */
   public function __construct(
-    private readonly RequestStack $requestStack,
-    private readonly AttachmentsResponseProcessorInterface $attachmentsProcessor,
+    protected readonly RequestStack $requestStack,
+    protected \Closure $attachmentsProcessor,
   ) {}
 
   /**
@@ -40,13 +40,17 @@ public function onRespond(ResponseEvent $event): void {
       // Only operate on HTML responses from an HTMX request.
       return;
     }
-    $processedResponse = $this->attachmentsProcessor->processAttachments($response);
+    $processedResponse = $this->getAttachementsProcessor()->processAttachments($response);
     if (!($processedResponse instanceof HtmlResponse)) {
       throw new \TypeError("ResponseEvent::setResponse() requires an HtmlResponse object");
     }
     $event->setResponse($processedResponse);
   }
 
+  protected function getAttachementsProcessor(): AttachmentsResponseProcessorInterface {
+    return ($this->attachmentsProcessor)();
+  }
+
   /**
    * Sets \Drupal\Component\Utility\Html::$isAjax to TRUE.
    *
diff --git a/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
deleted file mode 100644
index b8a9cd3cc108..000000000000
--- a/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
+++ /dev/null
@@ -1,89 +0,0 @@
-<?php
-// phpcs:ignoreFile
-
-/**
- * This file was generated via php core/scripts/generate-proxy-class.php 'Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor' "core/lib/Drupal/Core/Render/Hypermedia".
- */
-
-namespace Drupal\Core\ProxyClass\Render\Hypermedia {
-
-    /**
-     * Provides a proxy class for \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor.
-     *
-     * @see \Drupal\Component\ProxyBuilder
-     */
-    class HtmxResponseAttachmentsProcessor implements \Drupal\Core\Render\AttachmentsResponseProcessorInterface
-    {
-
-        use \Drupal\Core\DependencyInjection\DependencySerializationTrait;
-
-        /**
-         * The id of the original proxied service.
-         *
-         * @var string
-         */
-        protected $drupalProxyOriginalServiceId;
-
-        /**
-         * The real proxied service, after it was lazy loaded.
-         *
-         * @var \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor
-         */
-        protected $service;
-
-        /**
-         * The service container.
-         *
-         * @var \Symfony\Component\DependencyInjection\ContainerInterface
-         */
-        protected $container;
-
-        /**
-         * Constructs a ProxyClass Drupal proxy object.
-         *
-         * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
-         *   The container.
-         * @param string $drupal_proxy_original_service_id
-         *   The service ID of the original service.
-         */
-        public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container, $drupal_proxy_original_service_id)
-        {
-            $this->container = $container;
-            $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id;
-        }
-
-        /**
-         * Lazy loads the real service from the container.
-         *
-         * @return object
-         *   Returns the constructed real service.
-         */
-        protected function lazyLoadItself()
-        {
-            if (!isset($this->service)) {
-                $this->service = $this->container->get($this->drupalProxyOriginalServiceId);
-            }
-
-            return $this->service;
-        }
-
-        /**
-         * {@inheritdoc}
-         */
-        public function processAttachments(\Drupal\Core\Render\AttachmentsInterface $response)
-        {
-            return $this->lazyLoadItself()->processAttachments($response);
-        }
-
-        /**
-         * {@inheritdoc}
-         */
-        public static function formatHttpHeaderAttributes(array $attributes = array (
-        ))
-        {
-            \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::formatHttpHeaderAttributes($attributes);
-        }
-
-    }
-
-}
-- 
GitLab


From 4dfed48b4bf8cb7fa5e4ed109c63088433ff2ed8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Th=C3=A9odore=20BIADALA?= <theodore@biadala.net>
Date: Fri, 2 May 2025 10:54:44 +0200
Subject: [PATCH 13/15] fix declaration

---
 core/core.services.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/core/core.services.yml b/core/core.services.yml
index 13e74fb5c003..ce288b667a3a 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -1405,7 +1405,7 @@ services:
     parent: html_response.attachments_processor
   htmx.response_subscriber:
     class: Drupal\Core\EventSubscriber\HtmxResponseSubscriber
-    arguments: [ '@request_stack', !service_closure Use '@htmx_response.attachments_processor' ]
+    arguments: [ '@request_stack', !service_closure '@htmx_response.attachments_processor' ]
     tags:
       - { name: event_subscriber }
   finish_response_subscriber:
-- 
GitLab


From 043e531dce9da8b2f0bd69353ce1483f45c35bf0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Th=C3=A9odore=20BIADALA?= <theodore@biadala.net>
Date: Fri, 2 May 2025 11:00:10 +0200
Subject: [PATCH 14/15] don't use inline template for the test button

---
 .../HtmxTestAttachmentsController.php         | 22 +++++++++++++------
 .../Tests/htmx/htmxAssetLoadTest.js           |  4 ++--
 2 files changed, 17 insertions(+), 9 deletions(-)

diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
index c90a7aec7dfd..b100d8adbea5 100644
--- a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
+++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
@@ -20,6 +20,20 @@ final class HtmxTestAttachmentsController extends ControllerBase {
    */
   public function page(): array {
     $url = Url::fromRoute('test_htmx.attachments.replace');
+    $build['replace'] = [
+      '#type' => 'button',
+      '#attributes' => [
+        'name' => 'replace',
+        'data-hx-get' => $url->toString(),
+        'data-hx-select' => 'div.dropbutton-wrapper',
+        'data-hx-target' => '[data-drupal-htmx-target]',
+      ],
+      '#value' => 'Click this',
+      '#context' => [
+        'url' => $url,
+      ],
+    ];
+
     $build['content'] = [
       '#type' => 'container',
       '#attached' => [
@@ -28,15 +42,9 @@ public function page(): array {
         ],
       ],
       '#attributes' => [
+        'data-drupal-htmx-target' => TRUE,
         'class' => ['htmx-test-container'],
       ],
-      'button' => [
-        '#type' => 'inline_template',
-        '#template' => '<button class="button" name="replace" data-hx-get="{{- url -}}" data-hx-select="div.dropbutton-wrapper" data-hx-swap="afterend">Click this</button>',
-        '#context' => [
-          'url' => $url,
-        ],
-      ],
     ];
 
     return $build;
diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
index ff75fe000cd2..8af529d826e1 100644
--- a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
+++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
@@ -32,9 +32,9 @@ module.exports = {
       .drupalRelativeURL('/htmx-test-attachments/page')
       .waitForElementVisible('body', 1000)
       .assert.not.elementPresent('script[src*="dropbutton.js"]')
-      .waitForElementVisible('button[name="replace"]', 1000)
+      .waitForElementVisible('[name="replace"]', 1000)
       .pause(1000)
-      .click('button[name="replace"]')
+      .click('[name="replace"]')
       .waitForElementVisible('div.dropbutton-wrapper', 60000)
       .waitForElementVisible('div[data-once="dropbutton"]', 60000)
       .assert.elementPresent('script[src*="dropbutton.js"]');
-- 
GitLab


From a636ed3546ad290b47c44ef852b2b802e671d030 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Th=C3=A9odore=20BIADALA?= <theodore@biadala.net>
Date: Fri, 2 May 2025 11:04:51 +0200
Subject: [PATCH 15/15] simplify the behaviors init

---
 core/core.libraries.yml          |  3 +--
 core/misc/htmx/htmx-behaviors.js | 28 +++++++++++++++++-----------
 2 files changed, 18 insertions(+), 13 deletions(-)

diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 255fc911ec60..48eb3b17a230 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -620,11 +620,10 @@ drupal.htmx:
     misc/htmx/htmx-assets.js: {}
     misc/htmx/htmx-behaviors.js: {}
   dependencies:
+    - core/htmx
     - core/drupal
     - core/drupalSettings
-    - core/htmx
     - core/loadjs
-    - core/once
 
 drupal.machine-name:
   version: VERSION
diff --git a/core/misc/htmx/htmx-behaviors.js b/core/misc/htmx/htmx-behaviors.js
index 6986d4c79531..90a44426736d 100644
--- a/core/misc/htmx/htmx-behaviors.js
+++ b/core/misc/htmx/htmx-behaviors.js
@@ -2,25 +2,31 @@
  * @file
  * Connect Drupal.behaviors to htmx inserted content.
  */
-(function (Drupal, once, htmx, drupalSettings) {
-  function htmxDrupalBehaviors(htmxLoadEvent) {
-    // The attachBehaviors method searches within the context.
-    // We need to go up one level so that the loaded content is processed
-    // completely.
+(function (Drupal, htmx, drupalSettings) {
+
+
+  // @todo replace with a custom event that will trigger once all the assets
+  // have been loaded.
+  htmx.on('htmx:load', (event) => {
     Drupal.attachBehaviors(
-      htmxLoadEvent.detail.elt?.parentElement,
+      // The attachBehaviors method searches within the context.
+      // We need to go up one level so that the loaded content is processed
+      // completely.
+      event.detail.elt?.parentElement,
       drupalSettings,
     );
-  }
+  });
 
-  /* Initialize listeners. */
-  window.addEventListener('htmx:load', htmxDrupalBehaviors);
-  window.addEventListener('htmx:oobAfterSwap', htmxDrupalBehaviors);
+  // When htmx removes elements from the DOM, make sure they're detached first.
+  htmx.on('htmx:beforeSwap', (event) => {
+    Drupal.detachBehaviors(event.detail.elt, drupalSettings, 'unload');
+  });
 
   /**
+   * @todo move that to ajax.js this is not necessary since htmx initializes the markup it adds to the page automatically.
    * Also send new markup through htmx processing.
    */
   Drupal.behaviors.htmxProcess = {
     attach: htmx.process,
   };
-})(Drupal, once, htmx, drupalSettings);
+})(Drupal, htmx, drupalSettings);
-- 
GitLab