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:
@@ -496,17 +496,24 @@ const App: Component = () => {
|
||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||
return (
|
||||
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
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}
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isVisible() ? "flex" : "none" }}
|
||||
data-instance-id={instance.id}
|
||||
data-instance-active={isActiveInstance() ? "true" : "false"}
|
||||
data-instance-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
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()}
|
||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||
|
||||
@@ -62,6 +62,9 @@ const log = getLogger("session")
|
||||
|
||||
interface InstanceShellProps {
|
||||
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
|
||||
paletteCommands: Accessor<Command[]>
|
||||
onCloseSession: (sessionId: string) => Promise<void> | void
|
||||
@@ -800,12 +803,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
>
|
||||
<For each={cachedSessionIds()}>
|
||||
{(sessionId) => {
|
||||
const isActive = () => activeSessionIdForInstance() === sessionId
|
||||
const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId
|
||||
return (
|
||||
<div
|
||||
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
||||
style={{ display: isActive() ? "flex" : "none" }}
|
||||
data-session-id={sessionId}
|
||||
data-instance-id={props.instance.id}
|
||||
data-session-active={isActive() ? "true" : "false"}
|
||||
aria-hidden={!isActive()}
|
||||
>
|
||||
<SessionView
|
||||
@@ -841,7 +846,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
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} />}>
|
||||
{sessionLayout}
|
||||
</Show>
|
||||
|
||||
@@ -202,5 +202,15 @@ export function Markdown(props: MarkdownProps) {
|
||||
|
||||
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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export function getMessageAnchorId(messageId: string) {
|
||||
return `message-anchor-${messageId}`
|
||||
}
|
||||
|
||||
const VIRTUAL_ITEM_MARGIN_PX = 300
|
||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||
|
||||
interface MessageBlockListProps {
|
||||
instanceId: string
|
||||
|
||||
@@ -381,7 +381,15 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
|
||||
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"}`}>
|
||||
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
|
||||
<div class="message-header-left">
|
||||
|
||||
@@ -131,7 +131,12 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
<Switch>
|
||||
<Match when={partType() === "text"}>
|
||||
<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>}>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
|
||||
@@ -913,13 +913,30 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
})
|
||||
|
||||
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
|
||||
class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}
|
||||
data-scroll-buttons={scrollButtonsCount()}
|
||||
>
|
||||
<div class="message-stream-shell" ref={setShellElement}>
|
||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
|
||||
<div
|
||||
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" }} />
|
||||
<Show when={!props.loading && messageIds().length === 0}>
|
||||
<div class="empty-state">
|
||||
|
||||
@@ -411,7 +411,14 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
})
|
||||
|
||||
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}>
|
||||
{(segment) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
|
||||
@@ -976,6 +976,12 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
setToolCallRootEl(element || undefined)
|
||||
}}
|
||||
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">
|
||||
<button
|
||||
|
||||
@@ -62,6 +62,42 @@ function shouldRenderEntry(entry: IntersectionObserverEntry) {
|
||||
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(
|
||||
target: Element,
|
||||
root: ObserverRoot,
|
||||
@@ -120,7 +156,10 @@ interface VirtualItemProps {
|
||||
export default function VirtualItem(props: VirtualItemProps) {
|
||||
const resolved = resolveChildren(() => props.children)
|
||||
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 [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
|
||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
||||
@@ -148,12 +187,12 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
})
|
||||
}
|
||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||
const shouldHideContent = createMemo(() => {
|
||||
if (props.forceVisible?.()) return false
|
||||
if (!virtualizationEnabled()) return false
|
||||
return !isIntersecting()
|
||||
})
|
||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||
|
||||
let wrapperRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -229,14 +268,44 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
|
||||
cleanupIntersectionObserver()
|
||||
if (!wrapperRef) {
|
||||
setIsIntersecting(true)
|
||||
setIsIntersecting(false)
|
||||
return
|
||||
}
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setIsIntersecting(true)
|
||||
return
|
||||
}
|
||||
|
||||
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) => {
|
||||
const nextVisible = shouldRenderEntry(entry)
|
||||
queueVisibility(nextVisible)
|
||||
@@ -340,4 +409,3 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user