Skip to content

fix(tui): render plugin slots and routes with component semantics - #39917

Merged
kitlangton merged 1 commit into
v2from
plugin-render-semantics
Jul 31, 2026
Merged

fix(tui): render plugin slots and routes with component semantics#39917
kitlangton merged 1 commit into
v2from
plugin-render-semantics

Conversation

@kitlangton

Copy link
Copy Markdown
Contributor

What

Plugin slot and route render functions were invoked as bare function calls inside the host's tracked reactive scope. Any reactive read in the returned JSX subscribed the host's resolution memo, so an update re-executed the plugin's entire render body — not a fine-grained update.

Before / After

Before: a plugin slot like

context.ui.slot("prompt.footer.end",()=>{const[tick,setTick]=createSignal(0)consttimer=setInterval(()=>setTick((v)=>v+1),1000)onCleanup(()=>clearInterval(timer))return<text>{tick()}</text>})

renders 0 forever. Instrumented sequence: body runs → tick() read subscribes the host memo → interval fires → memo re-runs the whole body → new signal at 0, old interval cleaned up → repeat. File markers show render / expr tick=0 / cleanup / render / ... in a loop — plugin-local state resets every update, and each cycle churns a create/cleanup pair. Anything stateful a plugin does in a slot (signals, timers, subscriptions) is silently broken.

After: the body runs exactly once and untracked, signals and intervals created inside are stable, and updates flow through the JSX bindings. The invariant is standard Solid component semantics: render bodies execute once; reactivity lives in the returned expressions.

How

packages/tui/src/plugin/context.tsx: invoke plugin renders through createComponent instead of calling them directly.

  • PluginSlot: createComponent(render, mergeProps(() => props.input))mergeProps keeps slot input reactive through a getter, so plugins still see prop changes without a remount.
  • PluginRoute: createComponent(render, { data: route.data.data }) — the surrounding memo still recreates the component on navigation, matching existing behavior.

Found while building the hot-reload demos for #39776, but the bug predates that branch — it has existed since plugin slots were introduced.

Scope

A deeper issue remains and is deliberately not addressed here: child expressions of plugin-returned JSX still resolve inside a host-tracked scope in some paths, so store-driven updates can remount rather than update fine-grained. That needs an OpenTUI-level fix and is tracked separately with full instrumentation notes.

Testing

  • bun typecheck and full bun run test in packages/tui (584 pass).
  • Behavior verified end-to-end on the hot-reload branch with instrumented ticker plugins (file-marker lifecycle logs) — the render/cleanup churn loop disappears and interval-driven store updates render live; see the demo videos in feat(tui): hot-reload local TUI plugins #39776.

@kitlangton
kitlangton merged commit dd0e41c into v2Jul 31, 2026
9 checks passed
@kitlangton
kitlangton deleted the plugin-render-semantics branch July 31, 2026 18:31
github-actionsBot pushed a commit to ReStranger/opencode that referenced this pull request Jul 31, 2026
* upstream/v2:
fix(tui): render plugin slots and routes with component semantics (anomalyco#39917)
refactor(ai): simplify provider options (anomalyco#39924)
refactor(core): rename attachments config to media (anomalyco#39927)
feat(tui): expand pasted text (anomalyco#39920)
feat(ai): expand OpenRouter native support (anomalyco#39907)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@kitlangton