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().
How each link behaves¶
| 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.cssstyles.frontend-editing__action.htmx-requestwith 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.