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 stamp getCurrentTime(), not getRequestTime(). 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 than STALE (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() and isApplicable() 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.

CommentFileSizeAuthor
#4 3621930_sample_ai.jpg134.97 KBmogtofu33
Command icon 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

mogtofu33 created an issue. See original summary.

mogtofu33’s picture

mogtofu33’s picture

Assigned: Unassigned » pdureau
Status: Active » Needs review
StatusFileSize
new134.97 KB

This is related to a work to be compatible with display_builder_ai as an island, here is an example:

Sample chat ai

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.

pdureau’s picture

Code 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 for display_builder_ai for now?

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.

If the answer is not always static, a method seems better.

isAvailable() and isApplicable() sit next to each other on the interface and read alike. A more distinct name (isEnabledForRequest()?) would remove the need for the disambiguating docblock.

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.

pdureau’s picture

Assigned: pdureau » mogtofu33
Status: Needs review » Reviewed & tested by the community

2 little questions to answer and we are good.

mogtofu33’s picture

You're right, removed alterRenderable

IslandInterface::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.

  • mogtofu33 committed a49a7b85 on 1.0.x
    task: #3621930 Island cannot decline, nor ask for the event stream
    
    By:...
mogtofu33’s picture

Assigned: mogtofu33 » Unassigned
Status: Reviewed & tested by the community » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

pdureau’s picture

You're right, removed alterRenderable

Ok

IslandInterface::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.

Thanks for the explanation.