Problem/Motivation
Two gaps in the island contract, both found while building a real island in another module (display_builder_ai), both general.
1. A View island cannot decline to render
IslandType::View islands in the sidebar get a labeled toolbar button from ProfileViewBuilder::buildStartButtons() (line 760), and main-region ones get a tab from buildDynamicTabs() (line 802). Both are built from the island list, never from what build() returned. So an island that has nothing to show still gets a button that opens an empty drawer.
Button islands do not have this problem: buildButtons() wraps build() in a <span>, so returning [] produces an empty wrapper and no affordance. That is why Collaboration::build() can return [] for a single user and look right.
isApplicable() cannot serve here. Its base implementation (IslandPluginBase.php:142) is $this->nodeId !== NULL && !empty($this->data), which is Contextual-island semantics - "is the selected node one I speak to" - re-asked on every selection. It is only consulted inside IslandPluginBase::build(), which every View panel bypasses, and no View island ever has a nodeId at assembly time. Overloading it would quietly change what a widely-used predicate means.
Concretely: an island gated on a permission, on a missing configuration entity, or on a service that is not there has no way to say "do not offer me".
2. The event stream is owned by one island
sse-connect and the display_builder/htmx_sse library are attached by Collaboration::alterRenderable(). The stream is a builder-wide transport that ProfileViewBuilder::buildPanes() already wires to every island through sse-swap, so any island can consume it - but only one island can open it, and that island ships status: false. A second consumer has to either force collaboration on or duplicate the wiring.
That method also assigns $build['#attributes'] outright rather than merging, so it discards attributes set by any other island's alterRenderable().
3. The SSE write is locked inside a controller
ApiControllerBase::saveSseData() is protected, so only the HTMX controllers can record a change for the stream to relay. Anything else that legitimately changes an instance - a queue worker, drush, an agent acting on the user's behalf - cannot.
Its stored sessionId is also misnamed for that wider use. The stream skips it because that session already received its out-of-band swaps in the response, not because it made the change. A writer that answered nobody has no session to name.
Proposed resolution
IslandInterface::isAvailable(): bool, defaulting TRUE in IslandPluginBase, filtered in ProfileViewBuilder::getIslandsEnableSorted() so a declining island contributes nothing: no pane, and no button or tab pointing at one. Answered on what the site and current user allow, never on builder state. Documented against isApplicable() so the two are not confused.
IslandInterface::needsSse(): bool, defaulting FALSE. The builder root opens the stream when any enabled island asks for it. The wiring leaves Collaboration::alterRenderable(), which becomes needsSse(): TRUE and so also loses the attribute-clobbering bug.
Note the cost this makes visible: ApiSseController is a sleep(2) loop holding one PHP worker per open builder, previously paid by nobody because the only consumer shipped disabled.
A display_builder.sse_state service owning the tempstore slot: record(), readLatest(), shouldRelay(). saveSseData() delegates to it. ApiControllerBase's now-orphaned SSE_COLLECTION constant and SharedTempStoreFactory injection go away. The record's sessionId becomes renderedBy, meaning "the session that already has this on screen"; a writer outside the HTMX cycle records NULL and is relayed to everyone, including whoever asked for it.
Two things worth carrying from the implementation, since neither is obvious and neither is caught by a test:
record()must stampgetCurrentTime(), notgetRequestTime(). Request time is frozen at process start, which an HTMX edit can afford because it is short. A long-running request writes a record already older thanSTALE(3s) and it is never delivered - silently: nothing errors, the canvas just never updates.shouldRelay()must read its keys defensively. The collection is shared and ephemeral, so it can hold a record written by an older release, and a PHP warning raised inside a streamed response is written into the event stream and breaks its framing.
Remaining tasks
- Decide whether
needsSse()should instead be a flag on the#[Island]attribute. It was written as a method because the answer is not always static, but the attribute is cheaper. isAvailable()andisApplicable()sit next to each other on the interface and read alike. A more distinct name (isEnabledForRequest()?) would remove the need for the disambiguating docblock.
User interface changes
An island may now be absent entirely rather than present-but-empty. Existing islands are unaffected: both new methods default to the current behavior.
API changes
Two methods added to IslandInterface. Any implementer not extending IslandPluginBase must add them. New display_builder.sse_state service. ApiControllerBase::SSE_COLLECTION removed, replaced by SseState::COLLECTION.
Data model changes
The display_builder_sse shared tempstore record renames sessionId to renderedBy. No migration: the collection is ephemeral with a 3 second relay window, and the read tolerates the old shape.
| Comment | File | Size | Author |
|---|
Issue fork display_builder-3621930
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
Comment #2
mogtofu33 commentedComment #4
mogtofu33 commentedThis is related to a work to be compatible with display_builder_ai as an island, here is an example:
The island leverage SSE so the AI chatbot can edit the current display. Previous version of display_builder_ai module was on a legacy _display_builder_route with a block in hook_page_bottom. But it was not working anymore and the Island plugin approach makes more sense.
Comment #5
pdureau commentedCode review
👍 That's cool to make SSE more generic instead of tighten to Collaboration island.
⚠️ Do we still need
IslandInterface::alterRenderable()? it seems our only use until now is the one we are removing from Collaboration island.⚠️ The explanation about the need for
IslandInterface::isAvailable()is clear, but we don't really implement this method it the MR. Is it normal? Is it only fordisplay_builder_aifor now?If the answer is not always static, a method seems better.
isAvailable(). Of course, it would be better to use an existing interface with such method, but i don't know any from Core.Feature review
OK.
There is a little bug when publishing but it has also been found in main branch, so it will be discussed in its own ticket.
Comment #6
pdureau commented2 little questions to answer and we are good.
Comment #7
mogtofu33 commentedYou're right, removed
alterRenderableIslandInterface::isAvailable()yes it is only now for display_builder_ai, it can't live in the AI module. A sidebar island can't hide its own toolbar button, because buildStartButtons() builds the button from the island list. Only ProfileViewBuilder can drop the island first. Splitting out would show a dead button if no chat permission.Comment #9
mogtofu33 commentedComment #11
pdureau commentedOk
Thanks for the explanation.