perf(ui): drop virtualized DOM in hidden panes

Add DOM instrumentation tags and harden VirtualItem visibility for hidden/zero-sized roots to prevent inactive instances from keeping heavy tool-call markup mounted; restore message stream virtualization margin.
This commit is contained in:
Shantur Rathore
2026-02-28 14:13:42 +00:00
parent c51e71c7a2
commit ca2b3c232f
10 changed files with 161 additions and 25 deletions

View File

@@ -496,17 +496,24 @@ const App: Component = () => {
const isActiveInstance = () => activeInstanceId() === instance.id const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection() const isVisible = () => isActiveInstance() && !showFolderSelection()
return ( return (
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}> <div
<InstanceMetadataProvider instance={instance}> class="flex-1 min-h-0 overflow-hidden"
<InstanceShell style={{ display: isVisible() ? "flex" : "none" }}
instance={instance} data-instance-id={instance.id}
escapeInDebounce={escapeInDebounce()} data-instance-active={isActiveInstance() ? "true" : "false"}
paletteCommands={paletteCommands} data-instance-visible={isVisible() ? "true" : "false"}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)} >
onNewSession={() => handleNewSession(instance.id)} <InstanceMetadataProvider instance={instance}>
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} <InstanceShell
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} instance={instance}
onExecuteCommand={executeCommand} isActiveInstance={isActiveInstance()}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()} onEnterMobileFullscreen={() => void enterMobileFullscreen()}

View File

@@ -62,6 +62,9 @@ const log = getLogger("session")
interface InstanceShellProps { interface InstanceShellProps {
instance: Instance instance: Instance
// Provided by App-level instance tabs; lets us pause heavy rendering
// work for inactive instances while keeping them mounted for fast switching.
isActiveInstance?: boolean
escapeInDebounce: boolean escapeInDebounce: boolean
paletteCommands: Accessor<Command[]> paletteCommands: Accessor<Command[]>
onCloseSession: (sessionId: string) => Promise<void> | void onCloseSession: (sessionId: string) => Promise<void> | void
@@ -800,12 +803,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
> >
<For each={cachedSessionIds()}> <For each={cachedSessionIds()}>
{(sessionId) => { {(sessionId) => {
const isActive = () => activeSessionIdForInstance() === sessionId const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId
return ( return (
<div <div
class="session-cache-pane flex flex-col flex-1 min-h-0" class="session-cache-pane flex flex-col flex-1 min-h-0"
style={{ display: isActive() ? "flex" : "none" }} style={{ display: isActive() ? "flex" : "none" }}
data-session-id={sessionId} data-session-id={sessionId}
data-instance-id={props.instance.id}
data-session-active={isActive() ? "true" : "false"}
aria-hidden={!isActive()} aria-hidden={!isActive()}
> >
<SessionView <SessionView
@@ -841,7 +846,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return ( return (
<> <>
<div class="instance-shell2 flex flex-col flex-1 min-h-0"> <div
class="instance-shell2 flex flex-col flex-1 min-h-0"
data-instance-id={props.instance.id}
>
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}> <Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
{sessionLayout} {sessionLayout}
</Show> </Show>

View File

@@ -202,5 +202,15 @@ export function Markdown(props: MarkdownProps) {
const proseClass = () => "markdown-body" const proseClass = () => "markdown-body"
return <div ref={containerRef} class={proseClass()} innerHTML={html()} /> return (
<div
ref={containerRef}
class={proseClass()}
data-view="markdown"
data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey}
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
innerHTML={html()}
/>
)
} }

View File

@@ -8,7 +8,7 @@ export function getMessageAnchorId(messageId: string) {
return `message-anchor-${messageId}` return `message-anchor-${messageId}`
} }
const VIRTUAL_ITEM_MARGIN_PX = 300 const VIRTUAL_ITEM_MARGIN_PX = 800
interface MessageBlockListProps { interface MessageBlockListProps {
instanceId: string instanceId: string

View File

@@ -381,7 +381,15 @@ export default function MessageItem(props: MessageItemProps) {
return ( return (
<div class={containerClass()}> <div
class={containerClass()}
data-view="message-item"
data-instance-id={props.instanceId}
data-session-id={props.sessionId}
data-message-id={props.record.id}
data-message-role={isUser() ? "user" : "assistant"}
data-message-status={props.record.status}
>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}> <header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}> <div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
<div class="message-header-left"> <div class="message-header-left">

View File

@@ -131,7 +131,12 @@ export default function MessagePart(props: MessagePartProps) {
<Switch> <Switch>
<Match when={partType() === "text"}> <Match when={partType() === "text"}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}> <Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}> <div
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
data-role={textContainerRole()}
data-part-type="text"
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}> <Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
<Markdown <Markdown
part={createTextPartForMarkdown()} part={createTextPartForMarkdown()}

View File

@@ -913,13 +913,30 @@ export default function MessageSection(props: MessageSectionProps) {
}) })
return ( return (
<div class="message-stream-container"> <div
class="message-stream-container"
data-instance-id={props.instanceId}
data-session-id={props.sessionId}
data-stream-active={isActive() ? "true" : "false"}
>
<div <div
class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`} class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}
data-scroll-buttons={scrollButtonsCount()} data-scroll-buttons={scrollButtonsCount()}
> >
<div class="message-stream-shell" ref={setShellElement}> <div
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}> class="message-stream-shell"
ref={setShellElement}
data-instance-id={props.instanceId}
data-session-id={props.sessionId}
>
<div
class="message-stream"
ref={setContainerRef}
onScroll={handleScroll}
onMouseUp={handleStreamMouseUp}
data-instance-id={props.instanceId}
data-session-id={props.sessionId}
>
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} /> <div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
<Show when={!props.loading && messageIds().length === 0}> <Show when={!props.loading && messageIds().length === 0}>
<div class="empty-state"> <div class="empty-state">

View File

@@ -411,7 +411,14 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}) })
return ( return (
<div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}> <div
class="message-timeline"
role="navigation"
aria-label={t("messageTimeline.ariaLabel")}
data-view="timeline"
data-instance-id={props.instanceId}
data-session-id={props.sessionId}
>
<For each={props.segments}> <For each={props.segments}>
{(segment) => { {(segment) => {
onCleanup(() => buttonRefs.delete(segment.id)) onCleanup(() => buttonRefs.delete(segment.id))

View File

@@ -976,6 +976,12 @@ export default function ToolCall(props: ToolCallProps) {
setToolCallRootEl(element || undefined) setToolCallRootEl(element || undefined)
}} }}
class={`tool-call ${combinedStatusClass()}`} class={`tool-call ${combinedStatusClass()}`}
data-part-type="tool"
data-tool-name={toolName()}
data-instance-id={props.instanceId}
data-session-id={props.sessionId}
data-message-id={props.messageId}
data-part-id={toolCallIdentifier()}
> >
<div class="tool-call-header"> <div class="tool-call-header">
<button <button

View File

@@ -62,6 +62,42 @@ function shouldRenderEntry(entry: IntersectionObserverEntry) {
return true return true
} }
function getViewportRect(): { top: number; bottom: number } {
if (typeof window === "undefined") {
return { top: 0, bottom: 0 }
}
return { top: 0, bottom: window.innerHeight }
}
function isRenderableRoot(root: ObserverRoot): boolean {
if (!root) return true
if (root instanceof Document) return true
if (typeof window === "undefined") return false
const element = root as Element
const style = window.getComputedStyle(element as Element)
if (style.display === "none" || style.visibility === "hidden") {
return false
}
const rect = (element as Element).getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
function shouldRenderByRects(params: {
wrapperRect: DOMRect
rootRect: { top: number; bottom: number }
margin: number
}): boolean {
const { wrapperRect, rootRect, margin } = params
const distanceAbove = rootRect.top - wrapperRect.bottom
const distanceBelow = wrapperRect.top - rootRect.bottom
const threshold = margin + VISIBILITY_BUFFER_PX
if (distanceAbove > threshold || distanceBelow > threshold) {
return false
}
return true
}
function subscribeToSharedObserver( function subscribeToSharedObserver(
target: Element, target: Element,
root: ObserverRoot, root: ObserverRoot,
@@ -120,7 +156,10 @@ interface VirtualItemProps {
export default function VirtualItem(props: VirtualItemProps) { export default function VirtualItem(props: VirtualItemProps) {
const resolved = resolveChildren(() => props.children) const resolved = resolveChildren(() => props.children)
const cachedHeight = sizeCache.get(props.cacheKey) const cachedHeight = sizeCache.get(props.cacheKey)
const [isIntersecting, setIsIntersecting] = createSignal(true) // Default to hidden until we can determine visibility.
// This avoids keeping heavy DOM alive when IntersectionObserver
// doesn't fire (common for hidden/zero-sized scroll roots).
const [isIntersecting, setIsIntersecting] = createSignal(false)
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0) const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0)
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined) const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0) let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
@@ -148,12 +187,12 @@ export default function VirtualItem(props: VirtualItemProps) {
}) })
} }
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true) const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
const shouldHideContent = createMemo(() => { const shouldHideContent = createMemo(() => {
if (props.forceVisible?.()) return false if (props.forceVisible?.()) return false
if (!virtualizationEnabled()) return false if (!virtualizationEnabled()) return false
return !isIntersecting() return !isIntersecting()
}) })
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
let wrapperRef: HTMLDivElement | undefined let wrapperRef: HTMLDivElement | undefined
@@ -229,14 +268,44 @@ export default function VirtualItem(props: VirtualItemProps) {
function refreshIntersectionObserver(targetRoot: Element | Document | null) { function refreshIntersectionObserver(targetRoot: Element | Document | null) {
cleanupIntersectionObserver() cleanupIntersectionObserver()
if (!wrapperRef) { if (!wrapperRef) {
setIsIntersecting(true) setIsIntersecting(false)
return return
} }
if (typeof IntersectionObserver === "undefined") { if (typeof IntersectionObserver === "undefined") {
setIsIntersecting(true) setIsIntersecting(true)
return return
} }
const margin = props.threshold ?? DEFAULT_MARGIN_PX const margin = props.threshold ?? DEFAULT_MARGIN_PX
// If the scroll root is hidden / 0x0, IntersectionObserver can report
// `isIntersecting` in unexpected ways (often "true" with null rootBounds),
// which keeps heavy DOM alive in background tabs.
//
// In that state, force-hide and skip attaching the observer. When the
// pane becomes visible again, VirtualItem will re-run this setup and
// re-attach the observer.
const renderable = isRenderableRoot(targetRoot)
if (!renderable) {
setIsIntersecting(false)
return
}
// Compute an immediate best-effort visibility so switching tabs doesn't
// depend on the first IntersectionObserver callback.
try {
const rootRect =
targetRoot && !(targetRoot instanceof Document)
? (targetRoot as Element).getBoundingClientRect()
: null
const bounds = rootRect ? { top: rootRect.top, bottom: rootRect.bottom } : getViewportRect()
setIsIntersecting(
shouldRenderByRects({ wrapperRect: wrapperRef.getBoundingClientRect(), rootRect: bounds, margin }),
)
} catch {
// Ignore measurement failures; IntersectionObserver will correct us.
}
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => { intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
const nextVisible = shouldRenderEntry(entry) const nextVisible = shouldRenderEntry(entry)
queueVisibility(nextVisible) queueVisibility(nextVisible)
@@ -340,4 +409,3 @@ export default function VirtualItem(props: VirtualItemProps) {
</div> </div>
) )
} }