From 8fbdd6e8f345c2b66006dc1f23dfa5d94c926cc3 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 20 May 2026 22:22:03 +0200 Subject: [PATCH] fix(ui): guard resize observers against missing elements --- .changeset/guard-resize-observer-elements.md | 5 +++++ src/client/src/components/PiWebApp.ts | 22 ++++++++++++++------ src/client/src/components/TerminalPanel.ts | 19 +++++++++++------ src/client/src/components/WorkspacePanel.ts | 11 +++++++--- 4 files changed, 42 insertions(+), 15 deletions(-) create mode 100644 .changeset/guard-resize-observer-elements.md diff --git a/.changeset/guard-resize-observer-elements.md b/.changeset/guard-resize-observer-elements.md new file mode 100644 index 0000000..7b3d09c --- /dev/null +++ b/.changeset/guard-resize-observer-elements.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Prevent resize observers from attaching to missing UI elements during panel rerenders. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 602e50b..b86b6b8 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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` diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index 0e2cd91..1b0f851 100644 --- a/src/client/src/components/TerminalPanel.ts +++ b/src/client/src/components/TerminalPanel.ts @@ -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); } diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 8403a8f..57e2cc9 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -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,