#1493·opentui

Memory Leaks: Native RssAnon grows ~3 MB/s with 1000 keyed <text> in <scrollbox> under 10Hz ring updates; JS heap and node count stay flat

Author: codehzCreated Sep 11, 2026Updated Sep 11, 2026

Environment

  • @opentui/core / @opentui/react 0.5.11 (also 0.5.4)
  • bun 1.4.2, linux x64
  • createTestRenderer (bufferedOutput: "memory")

What

1000 <text> children in a sticky <scrollbox>. Every 100ms drop the oldest row and append a new one with a monotonic React key.

JS heap plateaus (~+3MB). renderer.root child count stays 1003. /proc/self/status RssAnon climbs 3 MB/s (+70MB / 15s).

Idle (same 1000 nodes, 10Hz renderOnce, no mutation): RssAnon ~0. Stable keys (key=slot, only last line text changes): ~9× slower, roughly plateaus.

Repro

typescript
#!/usr/bin/env bun
/**
 * Minimal repro: native RSS grows while JS heap and renderable count stay flat.
 *
 *   bun add @opentui/[email protected] @opentui/[email protected] react
 *   bun opentui-native-rss-repro.tsx
 *   bun opentui-native-rss-repro.tsx --idle
 *
 * ring (default): 1000 <text> in <scrollbox>, 10Hz ring buffer, key=monotonic id
 * idle:           same 1000 lines, no mutation, still 10Hz native frames
 *
 * Observed @ 0.5.4 and 0.5.11 (linux x64, bun 1.4):
 *   ring  15s  RssAnon +~70MB  (~3 MB/s)  heap +~3MB  nodes=1003 constant
 *   idle  15s  RssAnon ~0
 */
/** @jsxImportSource @opentui/react */

import { createTestRenderer } from "@opentui/core/testing";
import { createRoot, flushSync } from "@opentui/react";
import { useRef, useState } from "react";
import corePkg from "@opentui/core/package.json";

const CAPACITY = 1000;
const INTERVAL_MS = 100;
const SECONDS = Number(process.argv.includes("--seconds") ? process.argv[process.argv.indexOf("--seconds") + 1] : 15);
const IDLE = process.argv.includes("--idle");

type Row = { id: number; text: string };

function seed(): Row[] {
  return Array.from({ length: CAPACITY }, (_, i) => ({
    id: i + 1,
    text: `world:tick:sync tick=${i + 1}`,
  }));
}

function push(prev: Row[], id: number): Row[] {
  const next = prev.slice(1);
  next.push({ id, text: `world:tick:sync tick=${id}` });
  return next;
}

function App({ driver }: { driver: { tick?: () => void } }) {
  const [rows, setRows] = useState(seed);
  const nextId = useRef(CAPACITY + 1);
  driver.tick = () => {
    const id = nextId.current++;
    setRows((prev) => push(prev, id));
  };
  return (
    <box width={120} height={40}>
      <scrollbox stickyScroll stickyStart="bottom" height="100%" scrollY overflow="hidden">
        {rows.map((row) => (
          <text key={row.id} wrapMode="none" truncate>
            {row.text}
          </text>
        ))}
      </scrollbox>
    </box>
  );
}

function parseStatus(text: string) {
  let rssKb = 0;
  let rssAnonKb = 0;
  for (const line of text.split("\n")) {
    if (line.startsWith("VmRSS:")) rssKb = Number.parseInt(line.replace(/\D+/g, ""), 10) || 0;
    if (line.startsWith("RssAnon:")) rssAnonKb = Number.parseInt(line.replace(/\D+/g, ""), 10) || 0;
  }
  return { rssKb, rssAnonKb };
}

function countNodes(node: { getChildren?: () => unknown[] }): number {
  return 1 + (node.getChildren?.() ?? []).reduce((n, c) => n + countNodes(c as { getChildren?: () => unknown[] }), 0);
}

const test = await createTestRenderer({
  width: 120,
  height: 40,
  useMouse: false,
  gatherStats: true,
  consoleMode: "disabled",
  screenMode: "main-screen",
  bufferedOutput: "memory",
});
const root = createRoot(test.renderer);
const driver: { tick?: () => void } = {};
flushSync(() => root.render(<App driver={driver} />));
await test.renderOnce();

const started = performance.now();
console.log(`# opentui ${corePkg.version} bun ${Bun.version} ${process.platform} ${process.arch} mode=${IDLE ? "idle" : "ring"}`);
console.log("t_s\trss_kb\trss_anon_kb\theap_kb\tnodes");

const sample = async (t: number) => {
  Bun.gc(true);
  const mu = process.memoryUsage();
  const st = parseStatus(await Bun.file("/proc/self/status").text());
  console.log(`${t.toFixed(1)}\t${st.rssKb}\t${st.rssAnonKb}\t${Math.round(mu.heapUsed / 1024)}\t${countNodes(test.renderer.root)}`);
};

await sample(0);
let last = -1;
while (performance.now() - started < SECONDS * 1000) {
  const t0 = performance.now();
  if (!IDLE) flushSync(() => driver.tick?.());
  await test.renderOnce();
  const t = (performance.now() - started) / 1000;
  const bucket = Math.floor(t);
  if (bucket !== last && bucket > 0) {
    last = bucket;
    await sample(t);
  }
  const wait = INTERVAL_MS - (performance.now() - t0);
  if (wait > 1) await Bun.sleep(wait);
}
await sample((performance.now() - started) / 1000);

root.unmount();
test.renderer.destroy();
bash
bun add @opentui/[email protected] @opentui/[email protected] react
bun opentui-native-rss-repro.tsx
bun opentui-native-rss-repro.tsx --idle

Expected

After warmup, RssAnon should be bounded like idle / stable-key.

Actual

ring mode RssAnon linear. Looks like Zig TextBuffer/Yoga pages are not returned when a keyed is unmounted (1 insert + 1 delete per tick).