Skip to content

AJAX transports

The ajax_content_update formatter setting decides whether an operation updates the field in place. How the request is issued is decided separately, from what the environment offers, with no module setting involved.

Transport Used when
htmx Core ships \Drupal\Core\Htmx\Htmx (Drupal 11.3 and later) or the htmx contrib module is installed
Classic Drupal AJAX Neither is present

HtmxAvailabilityInterface::isAvailable() is the single source of truth, cached per request. Every call site asks it rather than repeating the check, and no element ever carries both transports' attributes, since use-ajax and data-hx-* are mutually exclusive on one link.

The interface

isAvailable(): bool
applyGet(array &$element, Url $url, string $target = 'body', string $swap = 'none'): void
attachLibrary(array &$element): void

applyGet() wires an element to issue a GET through htmx, using core's Htmx value object where it exists and hand-built data-hx-* attributes plus the contrib library otherwise.

attachLibrary() attaches the htmx library unconditionally. It exists because not every htmx link goes through applyGet().

Link Classic htmx Target decided
Move up, move down, duplicate use-ajax class applyGet() with a placeholder target Server-side, per response, via HX-Retarget and HX-Reswap headers
Delete ajax-allowed class, JS calls Drupal.ajax(...).execute() after the dialog htmx-allowed class, JS calls window.htmx.ajax(...) after the dialog Same headers
Hidden reload-content link use-ajax class applyGet() with the real selector Known when the link is built

Move and duplicate links never know their own field wrapper selector at build time, under either transport. The controller decides it per response.

Delete is different in kind. The confirmation dialog has to run before any request fires, so it can never carry an auto-triggering attribute. It gets a marker class instead, and the JavaScript issues the request after confirmation.

Redirects

When a change made on the live page of moderated content is saved as a draft, FrontendEditingController::updateContent() sends the browser to the draft instead of returning the re-rendered field. It uses core's RedirectCommand for classic AJAX and the HX-Redirect response header for htmx. See Content moderation.

The page scrolls to the changed paragraph through sessionStorage['scroll_to'], which frontend_editing.js sets when a move or duplicate link is clicked and consumes on load. The delete link sets none, because its paragraph is gone.

Messages

Classic AJAX returns a MessageCommand. htmx has no equivalent, and an out-of-band swap would need a permanent container element on every page, which this module does not otherwise require.

Instead the controller sends an HX-Trigger response header carrying a frontendEditingMessage event with an array of {message, type} pairs. htmx events from that header bubble to <body>, where js/frontend_editing.js listens once and forwards each item to Drupal.Message().

Progress indicator

Drupal.ajax() inserts an .ajax-progress element. htmx inserts nothing: it toggles a .htmx-request class on the element it treats as the request's source, for the duration.

Two things make that visible:

  • frontend_editing.css styles .frontend-editing__action.htmx-request with the same spinner the classic throbber uses.
  • The delete link's JS call passes source: e.target. Without it, htmx.ajax() tags <body> instead of the link, and the spinner lands nowhere useful.

Testing the classic branch on a core that ships htmx

HtmxAvailability::isAvailable() caches its answer in a property. On Drupal 11.3 and later you cannot uninstall htmx to exercise the fallback, so force the cached value by reflection before anything else asks:

$htmx = \Drupal::service('frontend_editing.htmx_availability');
(new \ReflectionProperty($htmx, 'available'))->setValue($htmx, FALSE);

See FieldReferenceHelperTest::testAjaxContentUpdateAddsUseAjaxClassWithoutHtmx().

When htmx is missing on a page that should have it

Check whether the field actually renders a link that goes through applyGet(), meaning move, duplicate, or the hidden reload-content link. A field with only delete enabled never reaches it, so the library has to come from attachLibrary(), which is called wherever the module's own JavaScript library is attached.