Skip to content

Streaming text freeze: TextNodeRenderable.requestRender() silently drops renders when the node is transiently detached (parent === null) #1147

Closed as not planned

Description

@asingingbird

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, requestRender is 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 requestRender reaches the renderer.
  • Process is healthy: event loop cycling (voluntary_ctxt_switches rising), main thread idle in epoll_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).ctx

and 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/core 0.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

  1. simonklee commented on Aug 25, 2026

    @simonklee
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions