fix(ui): guard resize observers against missing elements

This commit is contained in:
Federico Jaramillo Martinez
2026-05-20 22:22:03 +02:00
parent 56d4614aad
commit 8fbdd6e8f3
4 changed files with 42 additions and 15 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Prevent resize observers from attaching to missing UI elements during panel rerenders.
+16 -6
View File
@@ -48,8 +48,8 @@ export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState();
@query("chat-view") private chatView?: ChatView;
@query("prompt-editor") private promptEditor?: PromptEditor;
@query(".context-items") private contextItems?: HTMLElement;
@query(".mobile-tabs") private mobileTabs?: HTMLElement;
@query(".context-items") private contextItems?: HTMLElement | null;
@query(".mobile-tabs") private mobileTabs?: HTMLElement | null;
private readonly sessions = new SessionController(
() => this.state,
@@ -696,7 +696,7 @@ export class PiWebApp extends LitElement {
}
private observeContextItems(): void {
const contextItems = this.contextItems;
const contextItems = this.contextItemsElement();
if (this.observedContextItems === contextItems) return;
this.contextItemsResizeObserver?.disconnect();
this.observedContextItems = contextItems;
@@ -709,7 +709,7 @@ export class PiWebApp extends LitElement {
}
private updateContextScrollState(): void {
const contextItems = this.contextItems;
const contextItems = this.contextItemsElement();
const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth);
const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1;
const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1;
@@ -717,8 +717,13 @@ export class PiWebApp extends LitElement {
if (this.contextCanScrollRight !== canScrollRight) this.contextCanScrollRight = canScrollRight;
}
private contextItemsElement(): HTMLElement | undefined {
const contextItems = this.contextItems;
return contextItems instanceof HTMLElement ? contextItems : undefined;
}
private observeMobileTabs(): void {
const mobileTabs = this.mobileTabs;
const mobileTabs = this.mobileTabsElement();
if (this.observedMobileTabs === mobileTabs) return;
this.mobileTabsResizeObserver?.disconnect();
this.observedMobileTabs = mobileTabs;
@@ -731,7 +736,7 @@ export class PiWebApp extends LitElement {
}
private updateMobileTabsScrollState(): void {
const mobileTabs = this.mobileTabs;
const mobileTabs = this.mobileTabsElement();
const maxScrollLeft = mobileTabs === undefined ? 0 : Math.max(0, mobileTabs.scrollWidth - mobileTabs.clientWidth);
const canScrollLeft = mobileTabs !== undefined && mobileTabs.scrollLeft > 1;
const canScrollRight = mobileTabs !== undefined && maxScrollLeft - mobileTabs.scrollLeft > 1;
@@ -739,6 +744,11 @@ export class PiWebApp extends LitElement {
if (this.mobileTabsCanScrollRight !== canScrollRight) this.mobileTabsCanScrollRight = canScrollRight;
}
private mobileTabsElement(): HTMLElement | undefined {
const mobileTabs = this.mobileTabs;
return mobileTabs instanceof HTMLElement ? mobileTabs : undefined;
}
override render() {
const state = this.state;
return html`
+13 -6
View File
@@ -18,7 +18,7 @@ const DEFAULT_TERMINAL_SIZE: TerminalSize = { cols: 100, rows: 30 };
export class TerminalPanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined;
@property({ type: Boolean }) autoStart = false;
@query(".terminal-host") private terminalHost?: HTMLDivElement;
@query(".terminal-host") private terminalHost?: HTMLDivElement | null;
@state() private terminals: TerminalInfo[] = [];
@state() private selectedId: string | undefined;
@state() private loading = false;
@@ -133,15 +133,16 @@ export class TerminalPanel extends LitElement {
private ensureTerminalView(): void {
const workspace = this.workspace;
if (!this.visible || this.terminal !== undefined || this.selectedId === undefined || this.terminalHost === undefined || workspace === undefined) return;
const terminalHost = this.terminalHostElement();
if (!this.visible || this.terminal !== undefined || this.selectedId === undefined || terminalHost === undefined || workspace === undefined) return;
const terminal = new Terminal(terminalOptions(this));
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(this.terminalHost);
terminal.open(terminalHost);
this.terminal = terminal;
this.fitAddon = fitAddon;
this.resizeObserver = new ResizeObserver(() => { this.fitAndNotify(); });
this.resizeObserver.observe(this.terminalHost);
this.resizeObserver.observe(terminalHost);
terminal.onData((data) => {
if (this.suppressTerminalInput) return;
const filtered = filterTerminalInput(data);
@@ -211,17 +212,23 @@ export class TerminalPanel extends LitElement {
private measureTerminalSize(): TerminalSize | undefined {
const currentSize = this.fitTerminal();
if (currentSize !== undefined) return currentSize;
if (this.terminal !== undefined || this.terminalHost === undefined) return undefined;
const terminalHost = this.terminalHostElement();
if (this.terminal !== undefined || terminalHost === undefined) return undefined;
const measuringTerminal = new Terminal(terminalOptions(this));
const measuringFitAddon = new FitAddon();
measuringTerminal.loadAddon(measuringFitAddon);
measuringTerminal.open(this.terminalHost);
measuringTerminal.open(terminalHost);
const size = terminalSizeFromDimensions(measuringFitAddon.proposeDimensions());
measuringTerminal.dispose();
return size;
}
private terminalHostElement(): HTMLDivElement | undefined {
const terminalHost = this.terminalHost;
return terminalHost instanceof HTMLDivElement ? terminalHost : undefined;
}
private applyTerminalTheme(): void {
if (this.terminal !== undefined) this.terminal.options.theme = terminalTheme(this);
}
+8 -3
View File
@@ -32,7 +32,7 @@ export class WorkspacePanel extends LitElement {
@property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined;
@property({ type: Number }) activeTerminalCount = 0;
@property({ type: Boolean }) terminalAutoStart = false;
@query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement;
@query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement | null;
@state() private workspaceHeaderCanScrollLeft = false;
@state() private workspaceHeaderCanScrollRight = false;
@@ -99,7 +99,7 @@ export class WorkspacePanel extends LitElement {
}
private observeWorkspaceHeaderStrip(): void {
const strip = this.workspaceHeaderStrip;
const strip = this.workspaceHeaderStripElement();
if (this.observedWorkspaceHeaderStrip === strip) return;
this.workspaceHeaderResizeObserver?.disconnect();
this.observedWorkspaceHeaderStrip = strip;
@@ -112,7 +112,7 @@ export class WorkspacePanel extends LitElement {
}
private updateWorkspaceHeaderScrollState(): void {
const strip = this.workspaceHeaderStrip;
const strip = this.workspaceHeaderStripElement();
const maxScrollLeft = strip === undefined ? 0 : Math.max(0, strip.scrollWidth - strip.clientWidth);
const canScrollLeft = strip !== undefined && strip.scrollLeft > 1;
const canScrollRight = strip !== undefined && maxScrollLeft - strip.scrollLeft > 1;
@@ -120,6 +120,11 @@ export class WorkspacePanel extends LitElement {
if (this.workspaceHeaderCanScrollRight !== canScrollRight) this.workspaceHeaderCanScrollRight = canScrollRight;
}
private workspaceHeaderStripElement(): HTMLElement | undefined {
const strip = this.workspaceHeaderStrip;
return strip instanceof HTMLElement ? strip : undefined;
}
private createPanelContext(workspace: Workspace): WorkspacePanelContext {
return {
workspace,