Repository navigation
Description
Summary
A non-root TextNodeRenderable reaches the renderer only by walking up the parent chain to RootTextNodeRenderable (the sole holder of ctx). Its requestRender() is:
// packages/core/src/renderables/TextNode.ts
public requestRender(): void {
this.markDirty()
this.parent?.requestRender() // <-- silent no-op when parent === null
}When a text node is transiently detached (parent set to null during a list re-diff / reorder / re-key) while it is still being mutated, markDirty() runs but this.parent?.requestRender() is a no-op. The render request is silently lost. If the actively-updating node is in that
state, the viewport stops repainting while application state keeps changing — a permanent render freeze until the tree is rebuilt.
This is reproducible via sst/opencode (which renders streaming model output into a SolidJS message list via @opentui/solid): the TUI viewport freezes mid-stream while the backend keeps producing output.
Affected versions
TextNode.ts is unchanged since 0.2.16 through the current main / 0.3.1:
git log v0.2.16..main -- packages/core/src/renderables/TextNode.ts # → 0 commits
So all current versions are affected.
Root cause
TextNodeRenderable has no reference to the RenderContext; only RootTextNodeRenderable does:
export class TextNodeRenderable extends BaseRenderable {
public requestRender(): void {
this.markDirty()
this.parent?.requestRender()
}
}
export class RootTextNodeRenderable extends TextNodeRenderable {
constructor(private readonly ctx: RenderContext, options, textParent) { super(options); /* ... */ }
public requestRender(): void {
this.markDirty()
this.ctx.requestRender()
}
}A non-root node depends on an unbroken parent chain. During a SolidJS <For>/<Index> re-diff, a node can be removed (parent = null) and re-inserted within the same flush. A reactive effect that still owns the old node instance writes to it during that window → markDirty() fires, parent?.requestRender() no-ops → render lost.
Evidence (from debugging in opencode)
Instrumented CliRenderer.requestRender and the application event stream:
- During streaming,
requestRenderis called continuously, then stops being called entirely at the freeze instant. - The application keeps emitting updates (text deltas) for 3–4 seconds afterward — i.e. signals/effects are alive and mutating nodes — but no
requestRenderreaches the renderer. - Process is healthy: event loop cycling (
voluntary_ctxt_switchesrising), main thread idle inepoll_wait, no exception, no blocked write syscall. - Quitting and resuming the session (which rebuilds the render tree fresh) recovers it; it can then re-freeze.
The renderer scheduler itself was exhaustively instrumented and exonerated (all requestRender/loop branches behave correctly; no thrown render errors). The break is strictly in the request propagation from a detached text node.
Proposed fix
Give TextNodeRenderable a RenderContext reference and fall back to it when detached:
export class TextNodeRenderable extends BaseRenderable {
protected _ctx?: RenderContext
public requestRender(): void {
this.markDirty()
if (this.parent) this.parent.requestRender()
else this._ctx?.requestRender() // detached but still updating
}
}Thread _ctx down on attach (add / insertBefore / replace), inherited from the parent/root:
// when assigning `child.parent = this`, also:
child._ctx = this._ctx ?? (this as RootTextNodeRenderable).ctxand set _ctx in RootTextNodeRenderable's constructor (this._ctx = ctx).
This makes detached-node renders self-healing without changing the happy-path behavior. (Implementation note: this must be done in source — adding a field to the compiled bundle breaks layout assumptions; the source build handles it correctly.)
Alternatives considered
- Re-queueing the node for render on reattach instead of a ctx fallback — also valid, but the ctx fallback is the minimal change and covers the "detached-but-still-written" window directly.
Environment
@opentui/core0.2.16 (bug present through 0.3.1 / main)- Reproduced via sst/opencode 1.15.13 (SolidJS reconciler
@opentui/solid) - Linux x64 (WSL2), but mechanism is platform-independent (pure JS render-request propagation; reproduces regardless of terminal emulator/GPU renderer)
Activity
simonklee commentedon Aug 25, 2026
TextNodeRenderable.requestRender does not call the renderer when parent is
null. Official add, remove, replace, insertBefore, and clear paths do not keep
that node in the text tree after detach.
A Solid For or Index move calls parent.add on the same parent. That path does
not set parent to null.
The parent that stays attached already calls requestRender. A later attach also
calls requestRender. The next gather includes text that changed while the node
had no parent.
OpenCode writes streaming assistant text with MarkdownRenderable.content. That
setter calls updateBlocks and requestRender on the markdown renderable. It does
not create or change TextNodeRenderable.
A retained RenderContext cannot show updates from a node that is not in the
tree.
If a stream freezes, open a new issue. Include a reproduction against the
markdown path or the renderer path.