diff --git a/docs/plans/2026-08-14-terminal-open-when-sized-design.md b/docs/plans/2026-08-14-terminal-open-when-sized-design.md new file mode 100644 index 0000000..cc36f31 --- /dev/null +++ b/docs/plans/2026-08-14-terminal-open-when-sized-design.md @@ -0,0 +1,90 @@ +# 终端延迟初始化(零尺寸容器 xterm open 崩溃修复)设计 + +**日期**:2026-08-14 +**状态**:已实施(本文档含实施偏差记录) +**目标版本**:v0.11.x(issue #25 修复) + +## 1. 目标 + +修复 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25):WKWebView 下点击「展开底部面板」后底部面板内容空白、无终端,控制台报 `Cannot read properties of undefined (reading 'dimensions')` 与 `(reading 'handleResize')`。 + +根因:`TerminalView` 的挂载 effect 在容器可能为零尺寸时**同步**执行 `term.open(host); fit.fit()`。xterm 5.3.0 在零尺寸容器里 open 时渲染器创建失败,`RenderService._renderer.value` 保持 undefined,随后 Viewport 的 `_innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)崩溃。Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现。 + +修复:把 `term.open(host)` + `fit.fit()` 推迟到容器有实际尺寸之后再执行(xterm 在隐藏/零尺寸容器初始化的标准修法),open 恰好执行一次。 + +## 2. 非目标 + +- 不换 xterm 渲染器(打包产物只有 DomRenderer 是现状,不是本次根因;`rendererType` 保持默认)。 +- 不改 pty / WS / 终端连接生命周期:推迟 open 不推迟连接、不重启 shell、不丢 transcript。 +- 不改 host 半与 chunk 打包结构(新模块随 terminal chunk 打入 `lib/client-terminal.js`)。 +- 不 bump 版本号(release 流程负责)。 + +## 3. 现状回顾 + +- `src/client/TerminalView.tsx` 第 99–101 行同步 `term.open(host); fit.fit()`。 +- 零尺寸挂载路径真实存在且不止一条: + - 底部面板首次展开自动开终端(`Sidebar.tsx` 的 `bottomOpenedOnce` effect)在面板 slide-in transition 的同一次 commit 挂载 TerminalView,WKWebView 下此刻 host 高度为 0; + - 所有 tab 保持挂载(`split-pane.tsx`:非激活 tab 用 `.paneTabHidden { display: none }` 隐藏),面板折叠时保持挂载(`.panelHidden`)——任何 `display:none` / 零尺寸祖先都会踩同一坑。 +- 前提已验证(xterm 5.3.0 与 @xterm/addon-fit 0.10.0 源码): + - `FitAddon.fit()` 在 open 之前是安全 no-op(`proposeDimensions` 先查 `this._terminal.element`,undefined 即返回)。 + - `WriteBuffer.write()` 不依赖 open 状态:数据解析进 buffer,open 后渲染——推迟 open 不丢初始 transcript。 + - `TerminalView` 已有的 ResizeObserver / 字体订阅里的 `fit.fit()` 在 open 前 no-op、open 后照常 re-fit,无需改动。 + +## 4. 设计 + +### 4.1 新模块 `src/client/open-when-sized.ts`(纯逻辑,可单测) + +```ts +export function openWhenSized( + host: HTMLElement, + open: () => void, + raf: (cb: FrameRequestCallback) => number = requestAnimationFrame, + caf: (id: number) => void = cancelAnimationFrame, +): () => void +``` + +- 单条代码路径:`raf(step)` → step 检查 `host.isConnected`(false 则停止,防卸载后空转)→ `clientWidth > 0 && clientHeight > 0` 则调 `open()` 并停止;否则再排一帧。 +- 返回 cancel:`caf(frame)` 并置空(幂等),cleanup 调用。 +- helper 不 try/catch(保持纯净),异常由调用方处理。 + +### 4.2 消费点 `src/client/TerminalView.tsx` + +- 移除 effect 顶部的 `term.open(host); fit.fit()`(保留 `term.loadAddon(fit)`)。 +- 在 effect 尾部(`sendResize` / `connect` 定义之后、`connect()` 调用之前)注册: + +```ts +const cancelOpen = openWhenSized(host, () => { + try { + term.open(host) + fit.fit() + sendResize() + } catch (error) { + console.error('[dsh-better-sidebar] xterm open failed:', error) + } +}) +``` + +- 回调里补 `sendResize()`:推迟路径下 socket 可能已先连上(`connect()` 在 effect 尾部同步调用),open+fit 后必须把真实 cols/rows 发出去,否则 pty 停留在默认 80x24 直到下次 resize;已尺寸快速路径下 socket 尚未 open,`sendResize` 内部守卫(`readyState === OPEN`)自然 no-op,无行为变化。 +- cleanup 里在 `term.dispose()` 前加 `cancelOpen()`。 +- 其余(connect / observer / fontSub / schemeSub / onData / 主题)不动。 + +## 5. 测试 + +`tests/open-when-sized.spec.ts`(jsdom + 注入手动步进的假 raf/caf;`Object.defineProperty` 桩 `clientWidth/clientHeight`;`appendChild/remove` 控制 `isConnected`): + +1. 已尺寸 → tick 一次后 open 恰好一次,之后不再排帧; +2. 零尺寸 → 多次 tick 不 open,设尺寸后下一次 tick open 一次,后续 tick 不重复; +3. 只有一维缺失 → 补齐后 open; +4. cancel 后永不 open、无残留帧、cancel 幂等; +5. host 脱离文档 → 轮询停止、不 open; +6. open 抛错 → 异常向调用方传播(调用方负责 try/catch),不重复调用。 + +## 6. 限制与取舍 + +- rAF 轮询在 host 长期隐藏(`display:none` 祖先)期间持续排帧,每帧只读两个属性,开销可忽略;open 后立即停止。用 ResizeObserver 触发也可行,但轮询是 issue 中建议的标准修法,且不依赖 observer 对 transition 中间态的回调时序。 +- 已尺寸快速路径比原行为晚一帧(rAF 回调时机),无感知差异。 +- 若容器永远零尺寸(异常布局),终端永不 open、不崩溃——对不可见内容是可接受的降级。 + +## 7. 实施偏差记录 + +无(按设计实施)。 diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index 83a34b5..d4ec208 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -24,6 +24,7 @@ import { Terminal, type ITheme } from 'xterm' import { FitAddon } from '@xterm/addon-fit' import 'xterm/css/xterm.css' import { t } from './locales.ts' +import { openWhenSized } from './open-when-sized.ts' import type { SessionScope } from './api.ts' import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts' import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts' @@ -97,8 +98,6 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: }) const fit = new FitAddon() term.loadAddon(fit) - term.open(host) - fit.fit() // Re-theme in place when the app's scheme flips (tokens + palette). const applyTheme = (): void => { term.options.theme = xtermTheme() @@ -208,9 +207,30 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: } }) + // The terminal must not be opened in a zero-size container: xterm's + // renderer creation fails there and the next Viewport refresh crashes + // reading `.dimensions` off the undefined renderer (blank terminal on + // WKWebView when the bottom panel's expand slide leaves the host at + // height 0; any display:none-hidden ancestor does the same). Defer + // open+fit until the host has a real size — writes arriving meanwhile + // are buffered by xterm's WriteBuffer and render once open, and + // FitAddon.fit() is a safe no-op before open. sendResize() here covers + // the deferred path where the socket may already be open with the + // default 80x24 dims. + const cancelOpen = openWhenSized(host, () => { + try { + term.open(host) + fit.fit() + sendResize() + } catch (error) { + console.error('[dsh-better-sidebar] xterm open failed:', error) + } + }) + connect() return () => { closed = true + cancelOpen() window.clearTimeout(retry) observer.disconnect() fontSub() diff --git a/src/client/open-when-sized.ts b/src/client/open-when-sized.ts new file mode 100644 index 0000000..481b554 --- /dev/null +++ b/src/client/open-when-sized.ts @@ -0,0 +1,43 @@ +/** + * Deferred one-shot open for hosts that may not have a real size yet. + * + * xterm's `Terminal.open()` must not run in a zero-size container: the + * renderer creation fails there (the DomRenderer is built from the host's + * dimensions), leaving the render service's renderer `undefined`, and the + * next Viewport refresh crashes reading `.dimensions` off it. WebKit-based + * hosts (WKWebView) reliably report zero while the bottom panel's expand + * slide is in flight; any `display:none`-hidden ancestor does the same. + * + * The caller's `open` callback (open + fit + resize) is invoked exactly + * once, on the first frame where the host reports a real size. While the + * host stays zero-sized the polling continues every frame; it stops when + * the host leaves the document (`isConnected`), so a pending open never + * fires after unmount. The returned cancel function drops a pending frame + * immediately (idempotent). + * + * `raf`/`caf` are injectable so tests can drive the polling deterministically. + */ +export function openWhenSized( + host: HTMLElement, + open: () => void, + raf: (cb: FrameRequestCallback) => number = requestAnimationFrame, + caf: (id: number) => void = cancelAnimationFrame, +): () => void { + let frame: number | null = null + const step = (): void => { + frame = null + if (!host.isConnected) return + if (host.clientWidth > 0 && host.clientHeight > 0) { + open() + return + } + frame = raf(step) + } + frame = raf(step) + return () => { + if (frame !== null) { + caf(frame) + frame = null + } + } +} diff --git a/tests/open-when-sized.spec.ts b/tests/open-when-sized.spec.ts new file mode 100644 index 0000000..2dcf7cf --- /dev/null +++ b/tests/open-when-sized.spec.ts @@ -0,0 +1,138 @@ +/** + * openWhenSized tests: the deferred one-shot open guard for hosts that may + * not have a real size yet (xterm crashes when opened in a zero-size + * container — the WKWebView bottom-panel blank-terminal bug, issue #25). + * The polling must open exactly once, only once the host reports a real + * size, stop when the host leaves the document, and cancel cleanly. + */ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { openWhenSized } from '../src/client/open-when-sized.ts' + +/** A manually-stepped raf/caf pair so the polling is deterministic. */ +function makeScheduler(): { + raf: (cb: FrameRequestCallback) => number + caf: (id: number) => void + tick: () => void + pending: () => number +} { + let nextId = 0 + const frames = new Map() + return { + raf: (cb) => { const id = ++nextId; frames.set(id, cb); return id }, + caf: (id) => { frames.delete(id) }, + tick: () => { + const cbs = [...frames.values()] + frames.clear() + for (const cb of cbs) cb(0) + }, + pending: () => frames.size, + } +} + +/** A host whose reported size can be changed between ticks. */ +function makeHost(width: number, height: number): { + el: HTMLElement + setSize: (w: number, h: number) => void +} { + const el = document.createElement('div') + document.body.appendChild(el) + const setSize = (w: number, h: number): void => { + Object.defineProperty(el, 'clientWidth', { value: w, configurable: true }) + Object.defineProperty(el, 'clientHeight', { value: h, configurable: true }) + } + setSize(width, height) + return { el, setSize } +} + +describe('openWhenSized', () => { + it('opens on the first tick when the host already has a size, then stops', () => { + const { raf, caf, tick, pending } = makeScheduler() + const host = makeHost(320, 200) + let opened = 0 + const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf) + tick() + expect(opened).toBe(1) + expect(pending()).toBe(0) + tick() + expect(opened).toBe(1) + cancel() + }) + + it('defers while the host is zero-sized and opens exactly once once sized', () => { + const { raf, caf, tick, pending } = makeScheduler() + const host = makeHost(0, 0) + let opened = 0 + const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf) + tick() + tick() + tick() + expect(opened).toBe(0) + expect(pending()).toBe(1) + host.setSize(320, 200) + tick() + expect(opened).toBe(1) + expect(pending()).toBe(0) + tick() + expect(opened).toBe(1) + cancel() + }) + + it('opens when only one dimension was missing', () => { + const { raf, caf, tick } = makeScheduler() + const host = makeHost(320, 0) + let opened = 0 + const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf) + tick() + expect(opened).toBe(0) + host.setSize(320, 120) + tick() + expect(opened).toBe(1) + cancel() + }) + + it('cancels a pending open and leaves no scheduled frames', () => { + const { raf, caf, tick, pending } = makeScheduler() + const host = makeHost(0, 0) + let opened = 0 + const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf) + tick() + cancel() + expect(pending()).toBe(0) + host.setSize(320, 200) + tick() + expect(opened).toBe(0) + // Idempotent: cancelling again is a no-op. + cancel() + }) + + it('stops polling when the host leaves the document', () => { + const { raf, caf, tick, pending } = makeScheduler() + const host = makeHost(0, 0) + let opened = 0 + const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf) + tick() + host.el.remove() + tick() + expect(opened).toBe(0) + expect(pending()).toBe(0) + // Even if the host somehow got a size after detaching, nothing opens. + host.setSize(320, 200) + tick() + expect(opened).toBe(0) + cancel() + }) + + it('does not swallow exceptions from open (the caller owns error handling)', () => { + const { raf, caf, tick } = makeScheduler() + const host = makeHost(320, 200) + let calls = 0 + const cancel = openWhenSized(host.el, () => { + calls += 1 + throw new Error('boom') + }, raf, caf) + expect(() => tick()).toThrow('boom') + expect(calls).toBe(1) + cancel() + }) +})