From 4e0163edc337a6e3ae85b518baffdb14f8c5066b Mon Sep 17 00:00:00 2001 From: "mengzhu.zhu" Date: Fri, 7 Aug 2026 19:30:37 +0800 Subject: [PATCH] fix: guard trend access against eager JSX evaluation (closes #5, #6) npm-installed plugins live under node_modules, which opencode/opentui exclude from the babel-preset-solid transform (opencode#39986). Bun then compiles the raw TSX with its generic JSX (jsxDEV), eagerly evaluating / children before guards run. With no session data yet, hasTrend is false and trend is undefined, so props.trend!.color threw TypeError and the per-slot ErrorBoundary silently unmounted the sidebar panel (#5, #6). - TuiHitRow: props.trend!.color -> props.trend?.color (safe under both eager and lazy evaluation) - Hardening: cache-ttl-view elapsed()!, main-session-view messages! -> optional props with runtime guards - tests/eager-render.test.ts: renders TuiHitRow with trend=undefined under bun's eager load path (verified to fail on the pre-fix !) - tests/eager-safe-jsx.test.ts: scans src tsx for guard-variable ! derefs and optional-chain-then-bare-deref patterns - AGENTS.md: Eager-safe JSX convention - devDep @opentui/solid 0.3.0 -> 0.4.5; version 0.6.3 -> 0.6.4 --- AGENTS.md | 1 + README.md | 2 +- README.zh-CN.md | 2 +- bun.lock | 23 +++++++++++++++++++- package.json | 4 +++- src/cache-ttl-view.tsx | 9 ++++---- src/main-session-view.tsx | 2 +- src/tui-panel/components.tsx | 2 +- tests/eager-render.test.ts | 42 ++++++++++++++++++++++++++++++++++++ tests/eager-safe-jsx.test.ts | 31 ++++++++++++++++++++++++++ 10 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 tests/eager-render.test.ts create mode 100644 tests/eager-safe-jsx.test.ts diff --git a/AGENTS.md b/AGENTS.md index f9b250c..c57b25f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ After moving or renaming exports: run full `bun test`; `tests/module-load.test.t - **`PLUGIN_ROOT`** in `load-config.ts` is `fileURLToPath(new URL("..", import.meta.url))` — do **not** wrap with an extra `dirname` (breaks config path). - **Sub-agent ids**: only from `session.list` overwrite in `child-session-sync.ts`; do not append via `session.get`. - **Agents UI totals**: child sessions only; main session excluded by design (see design doc). +- **Eager-safe JSX**: this plugin ships raw TSX; npm installs land under `node_modules`, where opencode/opentui skip the Solid transform (see [opencode#39986](https://github.com/anomalyco/opencode/issues/39986)), so bun's generic JSX compiles ``/`` children eagerly — accessing a guard variable's property inside children can throw on `undefined` before `when`/`each` runs. Never `!`-assert a guard variable in control-flow children; use `?.`/`??`, bind a local accessor, or accept `| undefined` in child props. Guard against `tests/eager-safe-jsx.test.ts`. - Comments only for non-obvious behavior. ## Configuration diff --git a/README.md b/README.md index 96355f6..189885d 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ Then reinstall via `Ctrl+P` → install plugin, and **restart OpenCode**. To avoid the pinning issue entirely, install a **pinned version** instead of `@latest`: ```jsonc -{ "plugin": ["opencode-cache-hit@0.6.3"] } +{ "plugin": ["opencode-cache-hit@0.6.4"] } ``` ## Compatibility diff --git a/README.zh-CN.md b/README.zh-CN.md index 57ea84c..740f033 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -221,7 +221,7 @@ rm -rf ~/.cache/opencode/packages/opencode-cache-hit@latest 要彻底避免固定问题,可安装**固定版本**而非 `@latest`: ```jsonc -{ "plugin": ["opencode-cache-hit@0.6.3"] } +{ "plugin": ["opencode-cache-hit@0.6.4"] } ``` ## 兼容性 diff --git a/bun.lock b/bun.lock index dea8078..32a4807 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "solid-js": "^1.9.0", }, "devDependencies": { + "@opentui/solid": "^0.4.5", "simple-git-hooks": "^2.13.1", }, "peerDependencies": { @@ -109,13 +110,17 @@ "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uUFVT3V35KkM1m8gaLmRcTV9dsJzXnxwM+dv6+NjScx0W/Y0CJKbW9wDYwnLyPnBNgaFUi171zmJra5gTtFTsw=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-73bNNNU2OaqZQLIlvzDOdAzQmzBAqf+cSilmJ+Y9JnybrBn1d6VShC66+V4xxIgonq1swk7BD+SUHYbwwGilQA=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jg5KrV/4mVQ0mdkcL9CtQVtBk0NAtQ+2rCKoZ/jNHB6GxGK0ot9vDV6P3X68hZVkvpb2pdXfg6GRsZJ+Np4hZA=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kiM3C5bwQBTfrJKAOfb+L3U6MMkPSQlMhAERlLMjqSurc+llcyqygr/wbXSvfAqJtKlIpf3MKJRnVFTyfRIdng=="], - "@opentui/solid": ["@opentui/solid@0.3.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-AUtNzvgkdW81Ftl0sahAy3tY1LIPSMzBw3APBC8jiDAzzPv4kYVdyWXryTxLbU2q+Pgtr57VwKwHgc5wsNrd2w=="], + "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -289,6 +294,8 @@ "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "@opentui/solid/@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -296,5 +303,19 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "@opentui/solid/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], + + "@opentui/solid/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], + + "@opentui/solid/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], + + "@opentui/solid/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], + + "@opentui/solid/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], + + "@opentui/solid/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], + + "@opentui/solid/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], } } diff --git a/package.json b/package.json index 0246daf..3da7b61 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "opencode-cache-hit", - "version": "0.6.3", + "version": "0.6.4", "description": "OpenCode TUI sidebar: prompt cache hit rate, tokens & cost with sub-agent rollup. Works with opencode-visual-cache; optional per-call JSONL timeline.", "type": "module", "license": "MIT", + "author": "mengzhu.zhu ", "repository": { "type": "git", "url": "https://github.com/zhumengzhu/opencode-cache-hit" @@ -65,6 +66,7 @@ "@opentui/solid": ">=0.2.0" }, "devDependencies": { + "@opentui/solid": "^0.4.5", "simple-git-hooks": "^2.13.1" } } diff --git a/src/cache-ttl-view.tsx b/src/cache-ttl-view.tsx index d8609f7..7fd581e 100644 --- a/src/cache-ttl-view.tsx +++ b/src/cache-ttl-view.tsx @@ -10,8 +10,9 @@ import { type CacheTTLConfig, DEFAULT_CACHE_TTL } from "./plugin-config.ts" import { getTTL, formatElapsed, DEFAULT_TTL_MS } from "./cache-ttl.ts" import type { PanelPalette, PanelLayout } from "./tui-panel/index.ts" -function findLastCacheActivity(messages: Accessor): AssistantMessage | null { - const msgs = messages() +function findLastCacheActivity(messages: Accessor | undefined): AssistantMessage | null { + const msgs = messages?.() + if (!msgs) return null for (let i = msgs.length - 1; i >= 0; i--) { const m = msgs[i] if ( @@ -26,7 +27,7 @@ function findLastCacheActivity(messages: Accessor): Assistan } export function CacheTTLView(props: { - messages: Accessor + messages?: Accessor config?: CacheTTLConfig pal: PanelPalette layout: PanelLayout @@ -77,7 +78,7 @@ export function CacheTTLView(props: { return ( - {props.layout.row(props.label, `${statusIcon()} ${formatElapsed(elapsed()!)}`, "")} + {props.layout.row(props.label, `${statusIcon()} ${formatElapsed(elapsed() ?? 0)}`, "")} ) diff --git a/src/main-session-view.tsx b/src/main-session-view.tsx index 96b52f8..ac3c7b8 100644 --- a/src/main-session-view.tsx +++ b/src/main-session-view.tsx @@ -48,7 +48,7 @@ export function MainSessionView(props: { [{props.bar}] {props.pct} - {props.trend!.text} + {props.trend?.text} ) diff --git a/tests/eager-render.test.ts b/tests/eager-render.test.ts new file mode 100644 index 0000000..b250a2f --- /dev/null +++ b/tests/eager-render.test.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from "bun:test" +import { testRender } from "@opentui/solid" +import { TuiHitRow } from "../src/tui-panel/components.tsx" + +/** + * Regression guard for opencode#5/#6: npm-installed plugins live under + * node_modules, where opencode/opentui skip babel-preset-solid, so bun's + * generic JSX (jsxDEV) eagerly evaluates children before the guard + * runs. bun test loads this raw TSX the same way (no solid transform), so + * rendering with an undefined optional prop must not throw. + */ +describe("eager render smoke (npm plugin load path)", () => { + test("TuiHitRow with trend=undefined does not throw", async () => { + await expect( + testRender(() => + TuiHitRow({ + label: "Hit", + bar: "||", + pct: "50%", + barColor: "blue", + textColor: "white", + trend: undefined, + }), + ), + ).resolves.toBeDefined() + }) + + test("TuiHitRow with a trend value renders", async () => { + await expect( + testRender(() => + TuiHitRow({ + label: "Hit", + bar: "||", + pct: "50%", + barColor: "blue", + textColor: "white", + trend: { text: "\u21932.0%", color: "green" }, + }), + ), + ).resolves.toBeDefined() + }) +}) diff --git a/tests/eager-safe-jsx.test.ts b/tests/eager-safe-jsx.test.ts new file mode 100644 index 0000000..eac39e0 --- /dev/null +++ b/tests/eager-safe-jsx.test.ts @@ -0,0 +1,31 @@ +import { describe, test, expect } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import path from "node:path" + +const srcDir = path.resolve(import.meta.dir, "../src") + +function collectTsx(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) collectTsx(full, out) + else if (entry.name.endsWith(".tsx")) out.push(full) + } + return out +} + +describe("eager-safe JSX (npm plugin loads under bun's generic JSX transform)", () => { + for (const file of collectTsx(srcDir)) { + test(path.relative(srcDir, file), () => { + const code = readFileSync(file, "utf8") + const problems: string[] = [] + const deref = code.match(/\w+!\s*[.\[]/) + if (deref) problems.push(`non-null assertion followed by deref: ${deref[0]}`) + const optChain = code.match(/\?\.\w+\s*[.\[]/) + if (optChain) problems.push(`optional chain then bare deref: ${optChain[0]}`) + expect( + problems, + `${path.relative(srcDir, file)}: opencode loads this raw TSX with bun's eager JSX (solid transform skips node_modules, see AGENTS.md "Eager-safe JSX"). These patterns throw on undefined before / guards run. Use ?. / ?? / a local accessor instead.`, + ).toEqual([]) + }) + } +})