Skip to content

Rendering pipelines

ViewAlter::viewAlter() finds fields by looking at the direct, top-level children of an entity's render array for #theme => field elements. A module that rebuilds that array into its own structure moves the fields somewhere the scan never looks, and no action links attach.

The Display Builder and Layout Builder sub-modules exist for exactly this. If you maintain another rendering pipeline, they are the reference implementations.

Reporting where fields ended up

After its own top-level scan, ViewAlter::viewAlter() dispatches CollectFieldRenderElementsEvent. The event carries the build by reference and three methods for reporting a location. Which one to use depends on how much of the original field structure survived your rebuild.

Location class Use when The base module then
FieldWrapperLocation The whole #theme => field wrapper survived, view mode and third-party settings intact, just nested deeper Runs it through the same per-field logic as a top-level field
FieldRenderElementLocation Only the individual field item survived. You resolve the add, delete, move and ajax-update flags yourself and pass them pre-resolved Attaches the action callbacks directly with those flags
FieldAjaxWrapperLocation A field's items share one parent array with no field-level properties to hang the wrapper selector on Wraps that parent in place for AJAX swaps. Add, move and delete still come separately

Layout Builder uses FieldWrapperLocation, because it nests fields without touching their wrappers, and because its fields are fully built by the time the render alter runs.

When the field is not built yet

Display Builder uses none of the three. A field placed through its UI sits inside a component element that is only a lazy scaffold when the render alter runs: its slots, and therefore the field's items, do not exist yet. Scanning finds nothing no matter how carefully you walk the tree.

The answer is to defer. DisplayBuilderFieldElementsSubscriber inserts its own #pre_render callback into the component element's chain, and does the work when that callback runs with the slots populated.

Warning

Insert the callback before the last default callback, not after it. The last default copies the slots by value into an inline template's context. Anything appended after that never reaches the output.

Display Builder also has a second shape. A field can be placed as a top-level source rather than nested in a component, and those are built eagerly, before the render alter. A subscriber has to handle both:

flowchart TB
    Event[FE_COLLECT_FIELD_RENDER_ELEMENTS] --> Q{Field shape?}
    Q -->|Top-level source,<br/>already built| Scan[Scan the build now]
    Q -->|Nested in a component,<br/>not built yet| Defer[Insert a pre-render callback]
    Defer --> Later[Callback runs once<br/>slots are populated]
    Later --> Attach[attachActionCallbacks]
    Scan --> Attach

Handling only the nested case looks correct against a test that nests the field on purpose, and silently breaks a real site that placed the field as a top-level source. Check the display's actual sources third-party setting rather than assuming a shape.

The other two events

ResolveFieldActionsEvent fires when the extra field is built directly, with no field render array to read the formatter settings from. Subscribe to report the add, move, delete and ajax flags, which the extra field then builds with buildCommonActions().

RenderFieldContentEvent fires when a single field has to be re-rendered for an AJAX swap and the normal $entity->get($field)->view($view_mode) returns empty, which happens for displays that do not render field by field. The first subscriber to call setContent() wins.

Warning

A RenderFieldContentEvent subscriber must attach the action callbacks to whatever it builds. The swapped-in markup is a fresh render with no callbacks of its own, so move, delete and add stop working after the first AJAX update otherwise.

The library

The frontend_editing/frontend_editing library is attached by the extra field's own render, or by the empty-field branch of ViewAlter::processField(). Nothing else attaches it, including attachActionCallbacks().

Without it on the page the links render but do nothing: the delete dialog never binds, and add falls through to a full page navigation. If a pipeline discards the entity build that carried the extra field, the library goes with it. See Display Builder.