Skip to content

Fix: drop stale <Static> output from fullStaticOutput on identity change#950

Merged
sindresorhus merged 3 commits into
vadimdemedes:masterfrom
chiga0:fix/static-output-not-reset-on-static-unmount
May 13, 2026
Merged

Fix: drop stale <Static> output from fullStaticOutput on identity change#950
sindresorhus merged 3 commits into
vadimdemedes:masterfrom
chiga0:fix/static-output-not-reset-on-static-unmount

Conversation

@chiga0

@chiga0 chiga0 commented May 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #949.

Summary

`Ink#fullStaticOutput` is the running concatenation of everything every `` instance has ever emitted during the lifetime of an Ink renderer. It is initialised to `''` in the constructor and only ever grows — no code path shrinks or resets it. When `shouldClearTerminalForFrame` later fires (overflow, leaving fullscreen, fullscreen unmount), Ink writes `clearTerminal + fullStaticOutput + output`, which means any item emitted by a `` that has since been unmounted/replaced re-materialises above the current viewport even though it no longer exists in the React tree.

Real-world symptom: "cleared history reappears after a long response scrolls the viewport" in TUIs that drive a Static replay via a remount key (Gemini CLI, qwen-code, …). End-to-end repro thread: QwenLM/qwen-code#4083.

Fix

Track `rootNode.previousStaticNode` across commits. When `resetAfterCommit` detects that `rootNode.staticNode` has changed identity (first mount, last unmount, or remount via key), fire a new `onStaticChange` hook on the root node before `onImmediateRender`. Ink installs an `onStaticChange` handler that resets `this.fullStaticOutput = ''`. The new instance (if any) re-emits its items into a fresh accumulator on the immediate render that follows.

```diff
// resetAfterCommit, src/reconciler.ts

  • if (rootNode.staticNode !== rootNode.previousStaticNode) {
  • rootNode.previousStaticNode = rootNode.staticNode;
  • if (typeof rootNode.onStaticChange === 'function') {
  •  rootNode.onStaticChange();
    
  • }
  • }
    ```

```diff
// constructor wiring, src/ink.tsx
this.rootNode.onImmediateRender = this.onRender;

  • this.rootNode.onStaticChange = this.handleStaticChange;
    ...
  • handleStaticChange = (): void => {
  • this.fullStaticOutput = '';
  • };
    ```

Scope (no behaviour change for the classic case)

  • The Tap-style "one Static mounted for the lifetime of the app, items keep getting appended" use case is untouched: `staticNode` identity never changes → `onStaticChange` never fires → `fullStaticOutput` keeps accumulating exactly as today.
  • The rootNode.staticNode dangling reference causes OOM after <Static> unmount #904 / fix: clear staticNode reference on Static unmount #905 unmount path is strengthened: in addition to clearing `rootNode.staticNode` (so the renderer stops walking a freed Yoga tree), Ink now also drops the now-orphan output from `fullStaticOutput`, so a later overflow-driven `clearTerminal` rewrite does not resurrect the unmounted Static's items.
  • The original `static output stops accumulating after Static unmounts (rootNode.staticNode dangling reference causes OOM after <Static> unmount #904)` regression test still passes — its length-stability assertion (`outputAfterChurn.length === outputAfterUnmount.length`) holds whether `fullStaticOutput` is left intact at unmount or reset at unmount; this PR doesn't change that invariant.

Test

Adds `fullStaticOutput is reset when unmounts so stale items are not replayed` to `test/components.tsx`. Mounts a `<Static items={['HISTORY-A', 'HISTORY-B']} />` in debug mode (every `stdout.write` is `fullStaticOutput + dynamicOutput`), unmounts it, asserts that the subsequent write does NOT include `HISTORY-A` or `HISTORY-B`. Without the fix the assertion fails; with the fix it passes.

The companion remount-via-key case is intentionally NOT tested here because it additionally depends on the `staticNode`-pointer-clobbered-on-remount bug fixed by #948. Each fix stands alone; this PR is independent of #948 and can be merged in either order. The two fixes compose to give the full "remount produces only new items, no resurrected old items" behaviour.

Verified:

  • New test fails on `master` (`76d221c`) — confirms the bug
  • New test passes with this patch
  • Existing Static tests all pass (`static output`, `skip previous output when rendering new static output`, `static output stops accumulating after Static unmounts (rootNode.staticNode dangling reference causes OOM after <Static> unmount #904)`, `render only new items in static output on final render`, non-interactive mode, concurrent mode)
  • Other test failures in the full suite (`cursor moves on space input`, `measure-element`-related, `wrap-ansi doesn't trim leading whitespace`) reproduce on `master` without this patch and are unrelated.

Why a hook on the root node and not inline in the reconciler

The reconciler owns the React → DOM mapping; `fullStaticOutput` is a private Ink-instance writer-side accumulator. A hook keeps the layering intact: the reconciler reports an identity change, Ink decides what to do with it. The same pattern is already used for `onRender`, `onImmediateRender`, and `onComputeLayout`.

`Ink#fullStaticOutput` is the running concatenation of everything every
`<Static>` instance has ever emitted during the lifetime of an Ink
instance. It's never cleared. When `shouldClearTerminalForFrame` later
fires (output overflow, leaving fullscreen, unmount), Ink writes
`clearTerminal + this.fullStaticOutput + output` to redraw the screen,
which means: any item that was emitted by a `<Static>` that has since
been unmounted (or replaced via key) re-materializes above the current
viewport.

For long-lived TUIs that drive Static replay by bumping a remount
`key` (e.g. Gemini CLI, qwen-code, anything with a /clear / model-
switch / Ctrl+O / auth-refresh path), the user-visible symptom is
"cleared chat history reappears after a long response scrolls the
viewport". The terminal scrollback contract is "what Ink wrote, you
see"; the bug is that Ink keeps writing items that no longer exist in
the React tree.

Fix: track `rootNode.previousStaticNode` across commits. When the
reconciler detects that `rootNode.staticNode` has changed identity
(first mount, last unmount, or remount via key), fire a new
`onStaticChange` hook on the root node BEFORE `onImmediateRender`.
Ink installs an `onStaticChange` handler that resets
`this.fullStaticOutput = ''`. The new instance (if any) re-emits its
items into a fresh accumulator on the immediate render that follows.

Scope:
- The classic Tap-style "one Static mounted for the lifetime of the
  app, items keep getting appended" use case is untouched: staticNode
  identity never changes, onStaticChange never fires.
- The vadimdemedes#904 unmount path is strengthened: in addition to clearing
  `rootNode.staticNode` (so the renderer stops walking a freed Yoga
  tree), Ink now also drops the now-orphan output from
  `fullStaticOutput`, so a later overflow-driven clearTerminal rewrite
  does not resurrect the unmounted Static's items.

Adds a regression test: mounts a `<Static>` with items HISTORY-A /
HISTORY-B in debug mode (every stdout.write is
`fullStaticOutput + dynamicOutput`), unmounts it, asserts that the
subsequent write does NOT include HISTORY-A or HISTORY-B.

The companion remount-via-key case is intentionally NOT tested here
because it additionally depends on the `staticNode`-pointer-clobbered-
on-remount bug being fixed separately (see vadimdemedes#948). Each fix stands on
its own; this PR is independent of vadimdemedes#948.

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review note submitted from local audit.

Generated By GPT-5 model

Comment thread test/components.tsx Outdated
Run prettier on test/components.tsx to fix CI lint failure flagged in
PR vadimdemedes#950 review.
@sindresorhus

Copy link
Copy Markdown
Collaborator

Can you fix the merge conflict?

…-not-reset-on-static-unmount

# Conflicts:
#	test/components.tsx
@chiga0

chiga0 commented May 13, 2026

Copy link
Copy Markdown
Contributor Author

Can you fix the merge conflict?

It's all clean now. Please check again.

By the way, cloud you please release package v7.0.3 after merged the pull request, thank you very much about that. Our package want to upgrade ink from v6.3 to v7, but find the issue. Thank you again.

@sindresorhus sindresorhus merged commit 669c438 into vadimdemedes:master May 13, 2026
2 checks passed
@chiga0 chiga0 deleted the fix/static-output-not-reset-on-static-unmount branch May 13, 2026 10:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fullStaticOutput retains items from unmounted/replaced <Static>, causing stale content to reappear on clearTerminal rewrites

2 participants