fix: 终端延迟到容器有尺寸后再 open(零尺寸初始化崩溃,WKWebView 底部面板展开后终端空白,#25)
This commit is contained in:
@@ -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. 实施偏差记录
|
||||
|
||||
无(按设计实施)。
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<number, FrameRequestCallback>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user