mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
92 lines
3.5 KiB
TypeScript
92 lines
3.5 KiB
TypeScript
import { describe, expect, it } from "bun:test"
|
|
import React, { useEffect, useState } from "react"
|
|
import { createTestRenderer } from "@opentui/core/testing"
|
|
import { createRoot } from "../src/reconciler/renderer.js"
|
|
|
|
/**
|
|
* Regression test for: Native Yoga crash when renderer.destroy() is called
|
|
* with pending React state updates.
|
|
*
|
|
* Bug report: https://gist.github.com/rauchg/b2e1e964f88773a5d08f5f682dce2224
|
|
*
|
|
* Issue: When calling renderer.destroy() while async operations (intervals,
|
|
* promises, etc.) are still updating React state, Yoga crashes with:
|
|
* "Cannot add child: Nodes with measure functions cannot have children."
|
|
* followed by "RuntimeError: Out of bounds memory access"
|
|
*
|
|
* The crash happens because:
|
|
* 1. renderer.destroy() is called (WITHOUT unmounting React first)
|
|
* 2. This calls root.destroyRecursively() which calls yogaNode.free() on all nodes
|
|
* 3. The interval keeps firing setLines() because React wasn't unmounted
|
|
* 4. React tries to re-render, calling appendChild() which calls add()
|
|
* 5. add() calls yogaNode.insertChild() on a freed (parent) yoga node
|
|
* 6. Yoga WASM crashes with out-of-bounds memory access
|
|
*
|
|
* The "Cannot add child: Nodes with measure functions cannot have children"
|
|
* error message is misleading - it's actually a use-after-free crash where
|
|
* the freed yoga node's memory has been reused/corrupted.
|
|
*
|
|
* CRITICAL: The bug only occurs when React is NOT unmounted before/during destroy.
|
|
* In the bug report, there is NO root.unmount() call - just renderer.destroy().
|
|
*/
|
|
|
|
describe("Renderer Destroy Crash with Pending React Updates", () => {
|
|
it("should not crash when renderer is destroyed without unmounting React while interval updates state", async () => {
|
|
// This test reproduces the EXACT scenario from the bug report:
|
|
// - Component has an interval updating state
|
|
// - renderer.destroy() is called WITHOUT calling root.unmount()
|
|
// - The interval continues to fire setLines() AFTER destroy
|
|
// - React tries to re-render on destroyed Yoga nodes -> CRASH
|
|
|
|
const testSetup = await createTestRenderer({
|
|
width: 40,
|
|
height: 20,
|
|
// CRITICAL: NO onDestroy callback - React is NOT unmounted
|
|
// This is exactly what happens in the bug report
|
|
})
|
|
|
|
const root = createRoot(testSetup.renderer)
|
|
|
|
function App() {
|
|
const [lines, setLines] = useState<string[]>([])
|
|
|
|
useEffect(() => {
|
|
// Interval keeps firing after destroy() because React isn't unmounted
|
|
const interval = setInterval(() => {
|
|
setLines((prev) => [...prev, `Line ${prev.length + 1}`])
|
|
}, 5)
|
|
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
return (
|
|
<box flexDirection="column" border borderStyle="single">
|
|
<text bold>OpenTUI Crash Repro</text>
|
|
{lines.slice(-10).map((line, i) => (
|
|
<text key={`line-${i}-${line}`}>{line}</text>
|
|
))}
|
|
</box>
|
|
)
|
|
}
|
|
|
|
root.render(<App />)
|
|
|
|
// Let the component mount and interval start
|
|
await testSetup.renderOnce()
|
|
await Bun.sleep(30)
|
|
await testSetup.renderOnce()
|
|
|
|
// Destroy WITHOUT unmounting React - this is the bug!
|
|
// The interval will keep firing setLines() after this
|
|
// React will try to add new <text> elements to destroyed Yoga nodes
|
|
testSetup.renderer.destroy()
|
|
|
|
// Wait for interval to fire more updates after destroy
|
|
// This is when the crash occurs if the bug is present
|
|
await Bun.sleep(100)
|
|
|
|
// If we reach here without crashing, the bug is fixed
|
|
expect(true).toBe(true)
|
|
})
|
|
})
|