Merge PR #5495: fix(tui): guard DynamicBorder against uninitialized module-level theme (@roboomp)

This commit is contained in:
can1357
2026-07-14 23:11:10 +02:00
2 changed files with 45 additions and 6 deletions
@@ -0,0 +1,36 @@
import { describe, expect, it } from "bun:test";
import { getThemeByName, setThemeInstance, theme } from "../../theme/theme";
import { DynamicBorder } from "../dynamic-border";
describe("DynamicBorder", () => {
// Regression for #5366: extensions importing legacy pi UI components get a
// second `src` module graph whose module-level `theme` is never assigned by
// host startup. `render()` must degrade to plain glyphs instead of throwing
// "undefined is not an object (evaluating 'theme.boxRound')" and killing the
// TUI. Bun isolates modules per test file, so `theme` is undefined here.
it("renders plain glyphs when the module-level theme is uninitialized", () => {
expect(theme).toBeUndefined();
const border = new DynamicBorder(str => `<${str}>`);
const lines = border.render(4);
expect(lines).toEqual(["<────>"]);
});
it("degrades the default color to plain text when theme is uninitialized", () => {
expect(theme).toBeUndefined();
// The default color function must not dereference `theme.fg`.
const border = new DynamicBorder();
expect(border.render(3)).toEqual(["───"]);
});
it("paints with theme.boxRound.horizontal once the theme is initialized", async () => {
const loaded = await getThemeByName("dark");
if (!loaded) throw new Error("theme unavailable");
setThemeInstance(loaded);
const border = new DynamicBorder(str => `<${str}>`);
expect(border.render(3)).toEqual([`<${loaded.boxRound.horizontal.repeat(3)}>`]);
});
});
@@ -1,19 +1,21 @@
import type { Component } from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import { fgOrPlain, theme } from "../../modes/theme/theme";
/**
* Dynamic border component that adjusts to viewport width.
*
* Note: When used from hooks loaded via jiti, the global `theme` may be undefined
* because jiti creates a separate module cache. Always pass an explicit color
* function when using DynamicBorder in components exported for hook use.
* Note: the module-level `theme` may be `undefined` — when loaded through jiti
* (separate module cache) or from a second `src` module graph in npm-package
* installs, where the host bundle assigns `theme` but this copy never sees it
* (issue #5366). Both the default color and `render()` degrade to plain,
* unstyled output instead of crashing the TUI.
*/
export class DynamicBorder implements Component {
#color: (str: string) => string;
#cachedWidth = -1;
#cachedLines: string[] | undefined;
constructor(color: (str: string) => string = str => theme.fg("border", str)) {
constructor(color: (str: string) => string = str => fgOrPlain("border", str)) {
this.#color = color;
}
@@ -26,7 +28,8 @@ export class DynamicBorder implements Component {
if (this.#cachedLines && this.#cachedWidth === width) {
return this.#cachedLines;
}
const lines = [this.#color(theme.boxRound.horizontal.repeat(Math.max(1, width)))];
const horizontal = typeof theme === "undefined" ? "─" : theme.boxRound.horizontal;
const lines = [this.#color(horizontal.repeat(Math.max(1, width)))];
this.#cachedWidth = width;
this.#cachedLines = lines;
return lines;