From 15371df9ad029a551c6eb6a35aa12520ca521f48 Mon Sep 17 00:00:00 2001 From: Tehan Date: Fri, 7 Aug 2026 10:13:38 +0200 Subject: [PATCH] fix(tui): import Solid control-flow builtins from solid-js, not @opentui/solid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar and every /ctx-* command disappear, with no crash and nothing on stderr. Reproduces on clean master. The Solid transform routes ALL emitted imports through the single `moduleName` build-tui.ts passes it (`@opentui/solid`) — renderer helpers and control-flow builtins alike. `@opentui/solid` re-exports only its own renderer helpers; `For`, `Show`, `Index`, `Switch`, `Match`, `ErrorBoundary`, `Suspense` and `SuspenseList` live in solid-js. The `` at sidebar-content.tsx:997 therefore compiled to import { For as _$For } from "opentui:runtime-module:%40opentui%2Fsolid"; and the host threw while loading the plugin: Export named 'For' not found in module 'opentui:runtime-module:@opentui/solid' OpenCode swallows errors thrown while loading a TUI plugin, so the plugin was dropped with no diagnostic. This is latent on master today — the committed tui-compiled bundle carries the bad import; it turns fatal once the host resolves that virtual module strictly. Fix: after the transform, redirect any specifier the OpenTUI runtime does not export to the solid-js runtime module. The split is read from the real export sets at build time rather than hardcoded, so it keeps working when OpenTUI changes what it re-exports — a hardcoded list would relocate the bug to the next version bump. A name exported by neither module now fails the build instead of shipping a bundle that breaks the TUI silently. Verified against the live host: @opentui/solid 0.4.5 exposes 48 names and none of the eight builtins; solid-js exports all eight. Portal and Dynamic really do belong to @opentui/solid and are left alone. All 24 runtime specifiers in the rebuilt bundle resolve, build:tui is reproducible, and the sidebar returns. The specifier list moved to src/shared/tui-runtime-specifiers.ts so the build and the guard test read one source and cannot drift apart. It sits in src/shared/ rather than src/tui/ because build-tui.ts copies every file under src/tui/ into the shipped bundle, and this is build/test tooling. Regression test (src/tui/tui-compiled-runtime-imports.test.ts) resolves every compiled specifier against the actual export sets of all 8 rewritten modules, fails on any runtime module id outside that set, and separately asserts no solid-js-only builtin is imported from @opentui/solid. Red-checked: 2/3 fail against a bundle built with the pre-fix script; an injected import from an unknown runtime module fails with the module named; duplicating an entry in the specifier list and stubbing a module to an empty export set each fail their own assertion. Gates: plugin 3534/0, typecheck 0 across 3 packages, lint clean, check:tui-compiled PASS. --- packages/plugin/scripts/build-tui.ts | 96 +++++++++-- .../src/shared/tui-runtime-specifiers.ts | 32 ++++ .../tui-compiled/slots/sidebar-content.tsx | 2 +- .../tui/tui-compiled-runtime-imports.test.ts | 151 ++++++++++++++++++ 4 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 packages/plugin/src/shared/tui-runtime-specifiers.ts create mode 100644 packages/plugin/src/tui/tui-compiled-runtime-imports.test.ts diff --git a/packages/plugin/scripts/build-tui.ts b/packages/plugin/scripts/build-tui.ts index 705c1cfc4..f1d331cfa 100644 --- a/packages/plugin/scripts/build-tui.ts +++ b/packages/plugin/scripts/build-tui.ts @@ -2,20 +2,12 @@ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promi import { createRequire } from "node:module"; import { basename, dirname, join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { runtimeModuleId, TUI_RUNTIME_SPECIFIERS } from "../src/shared/tui-runtime-specifiers"; const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const sourceRoot = join(pluginRoot, "src/tui"); const outputRoot = join(pluginRoot, "src/tui-compiled"); -const runtimeSpecifiers = new Set([ - "@opentui/core", - "@opentui/core/testing", - "@opentui/solid", - "@opentui/solid/components", - "@opentui/solid/jsx-runtime", - "@opentui/solid/jsx-dev-runtime", - "solid-js", - "solid-js/store", -]); +const runtimeSpecifiers: Set = new Set(TUI_RUNTIME_SPECIFIERS); type TransformSolidSource = ( code: string, @@ -30,8 +22,82 @@ type SolidTransformModule = { transformSolidSource?: TransformSolidSource; }; -function runtimeModuleId(specifier: string): string { - return `opentui:runtime-module:${encodeURIComponent(specifier)}`; + +/** + * The Solid transform emits every runtime helper AND every control-flow builtin + * (`For`, `Show`, `Index`, `Switch`, `Match`, `ErrorBoundary`, `Suspense`, ...) + * as an import from a single `moduleName`, which we set to `@opentui/solid`. + * But `@opentui/solid` only re-exports its own renderer helpers: the builtins + * live in `solid-js`. An emitted `import { For } from "@opentui/solid"` therefore + * resolves against the host's registry and throws + * + * Export named 'For' not found in module 'opentui:runtime-module:@opentui/solid' + * + * which the host swallows, leaving no sidebar and no /ctx-* commands. + * + * Rather than hardcode the split (it moves between OpenTUI versions), read the + * real export sets at build time and redirect only the names the OpenTUI runtime + * genuinely lacks. A name missing from BOTH modules fails the build instead of + * shipping a bundle that silently breaks the TUI. + */ +async function loadRuntimeExportSets(): Promise<{ + openTui: Set; + solid: Set; +}> { + const [openTuiModule, solidModule] = await Promise.all([ + import("@opentui/solid"), + import("solid-js"), + ]); + return { + openTui: new Set(Object.keys(openTuiModule)), + solid: new Set(Object.keys(solidModule)), + }; +} + +const OPENTUI_SOLID_RUNTIME_ID = runtimeModuleId("@opentui/solid"); +const SOLID_JS_RUNTIME_ID = runtimeModuleId("solid-js"); + +// The transform emits one specifier per import statement, e.g. +// import { For as _$For } from "opentui:runtime-module:%40opentui%2Fsolid"; +const SINGLE_SPECIFIER_IMPORT = + /^import \{\s*([A-Za-z_$][\w$]*)(\s+as\s+[A-Za-z_$][\w$]*)?\s*\} from "([^"]+)";$/; + +function redirectBuiltinImports( + code: string, + exportSets: { openTui: Set; solid: Set }, + sourceFile: string, +): string { + const unresolved: string[] = []; + + const rewritten = code + .split("\n") + .map((line) => { + const match = SINGLE_SPECIFIER_IMPORT.exec(line); + if (!match) return line; + + const [, importedName, alias, moduleId] = match; + if (moduleId !== OPENTUI_SOLID_RUNTIME_ID) return line; + if (exportSets.openTui.has(importedName)) return line; + + if (!exportSets.solid.has(importedName)) { + unresolved.push(importedName); + return line; + } + + return `import {${alias ? ` ${importedName}${alias} ` : ` ${importedName} `}} from "${SOLID_JS_RUNTIME_ID}";`; + }) + .join("\n"); + + if (unresolved.length > 0) { + throw new Error( + `${sourceFile}: compiled TUI imports ${unresolved + .map((name) => `'${name}'`) + .join(", ")} which neither @opentui/solid nor solid-js exports. ` + + "Shipping this bundle would make the host drop the sidebar silently.", + ); + } + + return rewritten; } function asTransformSolidSource(mod: SolidTransformModule, from: string): TransformSolidSource { @@ -113,6 +179,7 @@ async function compileTsx( transformSolidSource: TransformSolidSource, sourceFile: string, outputFile: string, + exportSets: { openTui: Set; solid: Set }, ): Promise { const code = await readFile(sourceFile, "utf8"); const compiled = await transformSolidSource(code, { @@ -123,10 +190,11 @@ async function compileTsx( }); await mkdir(dirname(outputFile), { recursive: true }); - await writeFile(outputFile, compiled); + await writeFile(outputFile, redirectBuiltinImports(compiled, exportSets, sourceFile)); } const transformSolidSource = await loadTransformSolidSource(); +const runtimeExportSets = await loadRuntimeExportSets(); const files = await listSourceFiles(sourceRoot); await rm(outputRoot, { recursive: true, force: true }); @@ -142,7 +210,7 @@ for (const sourceFile of files) { // the sidebar freezes on its first paint. The virtual ids are required so // the compiled package binds the host process's single OpenTUI/Solid // runtime instead of loading a second copy from the plugin package. - await compileTsx(transformSolidSource, sourceFile, outputFile); + await compileTsx(transformSolidSource, sourceFile, outputFile, runtimeExportSets); } else { await copyPlainTypeScript(sourceFile, outputFile); } diff --git a/packages/plugin/src/shared/tui-runtime-specifiers.ts b/packages/plugin/src/shared/tui-runtime-specifiers.ts new file mode 100644 index 000000000..a7ac3d8fe --- /dev/null +++ b/packages/plugin/src/shared/tui-runtime-specifiers.ts @@ -0,0 +1,32 @@ +/** + * The module specifiers the compiled TUI is allowed to resolve through OpenCode's + * process-wide OpenTUI runtime registry (`opentui:runtime-module:`). + * + * Single source of truth, shared by `scripts/build-tui.ts` (which rewrites these + * specifiers during the Solid transform) and + * `src/tui/tui-compiled-runtime-imports.test.ts` (which verifies every emitted + * specifier names a real export). Keeping one list means the guard test cannot + * drift into skipping a specifier the build actually rewrites — a skipped + * specifier is exactly the silent hole the test exists to close. + * + * Lives in `src/shared/` rather than `src/tui/` on purpose: `build-tui.ts` copies + * every file under `src/tui/` into the shipped `src/tui-compiled/` bundle, and + * this list is build/test tooling that the runtime bundle must not carry. + */ +export const TUI_RUNTIME_SPECIFIERS = [ + "@opentui/core", + "@opentui/core/testing", + "@opentui/solid", + "@opentui/solid/components", + "@opentui/solid/jsx-runtime", + "@opentui/solid/jsx-dev-runtime", + "solid-js", + "solid-js/store", +] as const; + +export type TuiRuntimeSpecifier = (typeof TUI_RUNTIME_SPECIFIERS)[number]; + +/** Virtual module id OpenCode registers for a runtime specifier. */ +export function runtimeModuleId(specifier: string): string { + return `opentui:runtime-module:${encodeURIComponent(specifier)}`; +} diff --git a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx index 25a5417f1..25bb4c8a6 100644 --- a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx @@ -1,4 +1,4 @@ -import { For as _$For } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { For as _$For } from "opentui:runtime-module:solid-js"; import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid"; import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid"; import { effect as _$effect } from "opentui:runtime-module:%40opentui%2Fsolid"; diff --git a/packages/plugin/src/tui/tui-compiled-runtime-imports.test.ts b/packages/plugin/src/tui/tui-compiled-runtime-imports.test.ts new file mode 100644 index 000000000..e763d2ac5 --- /dev/null +++ b/packages/plugin/src/tui/tui-compiled-runtime-imports.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { TUI_RUNTIME_SPECIFIERS } from "../shared/tui-runtime-specifiers"; + +/** + * Every `opentui:runtime-module:*` import in the compiled TUI bundle must name an + * export the target module actually has. + * + * The Solid transform routes ALL of its emitted imports — renderer helpers and + * control-flow builtins (`For`, `Show`, `Index`, `Switch`, `Match`, ...) alike — + * through the single `moduleName` that `scripts/build-tui.ts` passes it, which is + * `@opentui/solid`. But `@opentui/solid` only re-exports its own renderer + * helpers; the builtins live in `solid-js`. So a bundle built without the + * redirect in `build-tui.ts` contains + * + * import { For as _$For } from "opentui:runtime-module:%40opentui%2Fsolid"; + * + * and the host throws `Export named 'For' not found` while loading the plugin. + * OpenCode swallows that error, so the only visible symptom is a missing sidebar + * and missing /ctx-* commands — no crash and nothing on stderr. + * + * This test resolves each specifier against the real module export sets instead + * of a hardcoded list, so it keeps holding when OpenTUI changes which names it + * re-exports. It covers every specifier `build-tui.ts` rewrites (via the shared + * `TUI_RUNTIME_SPECIFIERS` list) and fails on any runtime module id outside that + * set, so a future import from e.g. `solid-js/store` cannot slip through + * unverified. + */ +describe("compiled TUI runtime imports", () => { + const COMPILED_ROOT = join(import.meta.dir, "..", "tui-compiled"); + const IMPORT_PATTERN = /import \{([^}]+)\} from "opentui:runtime-module:([^"]+)"/g; + + function compiledFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + found.push(...compiledFiles(full)); + } else if (entry.endsWith(".ts") || entry.endsWith(".tsx")) { + found.push(full); + } + } + return found; + } + + /** Export sets for EVERY specifier build-tui.ts rewrites, keyed by module id. + * Built from the shared list so the test cannot cover fewer modules than the + * build rewrites. */ + async function loadExportSets(): Promise>> { + const entries = await Promise.all( + TUI_RUNTIME_SPECIFIERS.map( + async (specifier) => + [specifier, new Set(Object.keys(await import(specifier)))] as const, + ), + ); + return Object.fromEntries(entries); + } + + test("every runtime specifier exists in the module it is imported from", async () => { + const exportSets = await loadExportSets(); + + const unresolved: string[] = []; + let checked = 0; + + for (const file of compiledFiles(COMPILED_ROOT)) { + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(IMPORT_PATTERN)) { + const moduleId = decodeURIComponent(match[2] ?? ""); + const exports = exportSets[moduleId]; + if (!exports) { + // A runtime module id outside the rewritten set means the build + // emitted something this guard does not know how to verify. + // Failing is the only safe answer: skipping it silently is the + // exact hole that let the `For` misroute ship. + unresolved.push( + `${file}: imports from unknown runtime module '${moduleId}' — ` + + "add it to TUI_RUNTIME_SPECIFIERS so it can be verified", + ); + continue; + } + + for (const specifier of (match[1] ?? "").split(",")) { + const importedName = specifier + .trim() + .split(/\s+as\s+/)[0] + ?.trim(); + if (!importedName) continue; + checked += 1; + if (!exports.has(importedName)) { + unresolved.push( + `${file}: '${importedName}' is not exported by ${moduleId}`, + ); + } + } + } + } + + expect(checked).toBeGreaterThan(0); + expect(unresolved).toEqual([]); + }); + + test("every rewritten specifier resolves to a non-empty module", async () => { + // A specifier that stops resolving already fails loudly on its own: the + // dynamic import inside loadExportSets throws and takes the test with it. + // What that does NOT catch is a specifier that resolves to an empty module + // (a renamed or emptied subpath export), which would make the guard above + // report every import from it as unresolved for a misleading reason. + const exportSets = await loadExportSets(); + + const empty = TUI_RUNTIME_SPECIFIERS.filter( + (specifier) => (exportSets[specifier]?.size ?? 0) === 0, + ); + expect(empty).toEqual([]); + }); + + test("TUI_RUNTIME_SPECIFIERS has no duplicates", () => { + // Object.fromEntries silently collapses duplicate keys, so a duplicated + // entry would shrink the export-set map without any other signal. + expect([...new Set(TUI_RUNTIME_SPECIFIERS)]).toEqual([...TUI_RUNTIME_SPECIFIERS]); + }); + + test("solid control-flow builtins are imported from solid-js, not @opentui/solid", async () => { + const openTuiExports = new Set(Object.keys(await import("@opentui/solid"))); + const solidExports = new Set(Object.keys(await import("solid-js"))); + + // The builtins that belong to solid-js alone — the exact set the + // transform would otherwise misroute to @opentui/solid. + const misroutable = [...solidExports].filter((name) => !openTuiExports.has(name)); + + const violations: string[] = []; + for (const file of compiledFiles(COMPILED_ROOT)) { + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(IMPORT_PATTERN)) { + if (decodeURIComponent(match[2] ?? "") !== "@opentui/solid") continue; + for (const specifier of (match[1] ?? "").split(",")) { + const importedName = specifier + .trim() + .split(/\s+as\s+/)[0] + ?.trim(); + if (importedName && misroutable.includes(importedName)) { + violations.push(`${file}: '${importedName}' must come from solid-js`); + } + } + } + } + + expect(violations).toEqual([]); + }); +});