fix(ui): stabilize streaming follow mode
Disable follow-mode virtualization churn and simplify reasoning header layout so streaming thinking blocks stop nudging the scroll position while the list is pinned to bottom.
This commit is contained in:
@@ -571,7 +571,6 @@ interface MessageBlockProps {
|
|||||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
onFork?: (messageId?: string) => void
|
onFork?: (messageId?: string) => void
|
||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
onMeasureElementChange?: (element: HTMLElement | null) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageBlock(props: MessageBlockProps) {
|
export default function MessageBlock(props: MessageBlockProps) {
|
||||||
@@ -787,34 +786,10 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
return resultBlock
|
return resultBlock
|
||||||
})
|
})
|
||||||
|
|
||||||
let measuredBlockElement: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
measuredBlockElement = undefined
|
|
||||||
props.onMeasureElementChange?.(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
const visibleBlock = createMemo(() => {
|
|
||||||
const resolved = block()
|
|
||||||
if (!resolved || resolved.items.length === 0) return null
|
|
||||||
return resolved
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (visibleBlock()) return
|
|
||||||
if (!measuredBlockElement) return
|
|
||||||
measuredBlockElement = undefined
|
|
||||||
props.onMeasureElementChange?.(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={visibleBlock()}>
|
<Show when={block()}>
|
||||||
{(resolvedBlock) => (
|
{(resolvedBlock) => (
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
|
||||||
measuredBlockElement = el
|
|
||||||
props.onMeasureElementChange?.(el)
|
|
||||||
}}
|
|
||||||
class="message-stream-block"
|
class="message-stream-block"
|
||||||
data-message-id={resolvedBlock().record.id}
|
data-message-id={resolvedBlock().record.id}
|
||||||
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||||
@@ -1314,12 +1289,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||||
|
|
||||||
let headerEl: HTMLDivElement | undefined
|
|
||||||
let actionsEl: HTMLDivElement | undefined
|
|
||||||
let primaryEl: HTMLSpanElement | undefined
|
|
||||||
let metaMeasureEl: HTMLSpanElement | undefined
|
|
||||||
const [showMetaInline, setShowMetaInline] = createSignal(true)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
})
|
})
|
||||||
@@ -1347,33 +1316,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
||||||
|
|
||||||
const updateMetaLayout = () => {
|
|
||||||
if (!hasMeta()) return
|
|
||||||
if (!headerEl || !actionsEl || !primaryEl || !metaMeasureEl) return
|
|
||||||
|
|
||||||
const headerWidth = headerEl.getBoundingClientRect().width
|
|
||||||
const actionsWidth = actionsEl.getBoundingClientRect().width
|
|
||||||
const primaryWidth = primaryEl.getBoundingClientRect().width
|
|
||||||
const metaWidth = metaMeasureEl.getBoundingClientRect().width
|
|
||||||
|
|
||||||
const availableLeft = Math.max(0, headerWidth - actionsWidth - 12)
|
|
||||||
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!hasMeta() || typeof ResizeObserver === "undefined") {
|
|
||||||
setShowMetaInline(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMetaLayout()
|
|
||||||
const observer = new ResizeObserver(() => updateMetaLayout())
|
|
||||||
if (headerEl) observer.observe(headerEl)
|
|
||||||
if (actionsEl) observer.observe(actionsEl)
|
|
||||||
if (primaryEl) observer.observe(primaryEl)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
|
||||||
|
|
||||||
const reasoningText = () => {
|
const reasoningText = () => {
|
||||||
const part = props.part as any
|
const part = props.part as any
|
||||||
if (!part) return ""
|
if (!part) return ""
|
||||||
@@ -1452,7 +1394,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="delete-hover-scope message-reasoning-card">
|
<div class="delete-hover-scope message-reasoning-card">
|
||||||
<div class="message-reasoning-header" ref={(el) => (headerEl = el)}>
|
<div class="message-reasoning-header">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-reasoning-toggle"
|
class="message-reasoning-toggle"
|
||||||
@@ -1461,7 +1403,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||||
>
|
>
|
||||||
<span class="message-reasoning-label">
|
<span class="message-reasoning-label">
|
||||||
<span class="message-reasoning-label-primary" ref={(el) => (primaryEl = el)}>
|
<span class="message-reasoning-label-primary">
|
||||||
<Show when={props.showDeleteMessage}>
|
<Show when={props.showDeleteMessage}>
|
||||||
<input
|
<input
|
||||||
class="message-select-checkbox"
|
class="message-select-checkbox"
|
||||||
@@ -1482,43 +1424,10 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Show when={hasMeta() && showMetaInline()}>
|
|
||||||
<span class="message-step-meta-inline">
|
|
||||||
<Show when={agentIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={modelIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={hasMeta()}>
|
|
||||||
<span
|
|
||||||
ref={(el) => (metaMeasureEl = el)}
|
|
||||||
class="message-step-meta-inline message-step-meta-inline--measure"
|
|
||||||
>
|
|
||||||
<Show when={agentIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={modelIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="message-reasoning-actions" ref={(el) => (actionsEl = el)}>
|
<div class="message-reasoning-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
@@ -1567,7 +1476,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={hasMeta() && !showMetaInline()}>
|
<Show when={hasMeta()}>
|
||||||
<div class="message-reasoning-meta-row">
|
<div class="message-reasoning-meta-row">
|
||||||
<span class="message-step-meta-inline">
|
<span class="message-step-meta-inline">
|
||||||
<Show when={agentIdentifier()}>
|
<Show when={agentIdentifier()}>
|
||||||
|
|||||||
@@ -1066,7 +1066,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
renderItem={(messageId, index, options) => (
|
renderItem={(messageId, index) => (
|
||||||
<MessageBlock
|
<MessageBlock
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
@@ -1085,11 +1085,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
onFork={props.onFork}
|
onFork={props.onFork}
|
||||||
onContentRendered={() => {
|
onContentRendered={handleContentRendered}
|
||||||
options.notifyItemRendered()
|
|
||||||
handleContentRendered()
|
|
||||||
}}
|
|
||||||
onMeasureElementChange={options.registerMeasureElement}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderOverlay={() => (
|
renderOverlay={() => (
|
||||||
|
|||||||
@@ -30,14 +30,7 @@ export interface VirtualFollowListState {
|
|||||||
export interface VirtualFollowListProps<T> {
|
export interface VirtualFollowListProps<T> {
|
||||||
items: Accessor<T[]>
|
items: Accessor<T[]>
|
||||||
getKey: (item: T, index: number) => string
|
getKey: (item: T, index: number) => string
|
||||||
renderItem: (
|
renderItem: (item: T, index: number) => JSX.Element
|
||||||
item: T,
|
|
||||||
index: number,
|
|
||||||
options: {
|
|
||||||
registerMeasureElement: (element: HTMLElement | null) => void
|
|
||||||
notifyItemRendered: () => void
|
|
||||||
},
|
|
||||||
) => JSX.Element
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional stable DOM id for the item wrapper.
|
* Optional stable DOM id for the item wrapper.
|
||||||
@@ -381,7 +374,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleContentRendered() {
|
function handleContentRendered() {
|
||||||
scheduleAnchorScroll()
|
if (autoScroll() && !anchorLock()) {
|
||||||
|
scheduleAutoPinToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (anchorLock() && !autoScroll()) {
|
||||||
|
scheduleAnchorCorrection()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
@@ -521,25 +521,51 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pendingAutoPin = false
|
let pendingAutoPin = false
|
||||||
|
let pendingAutoPinFrame: number | null = null
|
||||||
|
|
||||||
|
function clearPendingAutoPinFrame() {
|
||||||
|
if (pendingAutoPinFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingAutoPinFrame)
|
||||||
|
pendingAutoPinFrame = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAutoPinToBottom() {
|
||||||
|
if (!containerRef) return false
|
||||||
|
if (!autoScroll()) return false
|
||||||
|
if (anchorLock()) return false
|
||||||
|
|
||||||
|
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||||
|
if (containerRef.scrollTop !== maxScrollTop) {
|
||||||
|
containerRef.scrollTop = maxScrollTop
|
||||||
|
lastKnownScrollTop = maxScrollTop
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleAutoPinToBottom() {
|
function scheduleAutoPinToBottom() {
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
if (pendingAutoPin) return
|
if (pendingAutoPin) return
|
||||||
pendingAutoPin = true
|
pendingAutoPin = true
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
const gen = scrollCompensationGen
|
const gen = scrollCompensationGen
|
||||||
|
|
||||||
// Flush in a microtask so adjustments land before the next paint.
|
// Flush in a microtask so adjustments land before the next paint,
|
||||||
|
// then re-apply on the next two frames to catch deferred layout.
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (gen !== scrollCompensationGen) return
|
if (gen !== scrollCompensationGen) return
|
||||||
pendingAutoPin = false
|
pendingAutoPin = false
|
||||||
if (!containerRef) return
|
if (!applyAutoPinToBottom()) return
|
||||||
if (!autoScroll()) return
|
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||||
if (anchorLock()) return
|
pendingAutoPinFrame = null
|
||||||
|
if (gen !== scrollCompensationGen) return
|
||||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
if (!applyAutoPinToBottom()) return
|
||||||
if (containerRef.scrollTop !== maxScrollTop) {
|
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||||
containerRef.scrollTop = maxScrollTop
|
pendingAutoPinFrame = null
|
||||||
lastKnownScrollTop = maxScrollTop
|
if (gen !== scrollCompensationGen) return
|
||||||
}
|
applyAutoPinToBottom()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,6 +654,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
pendingScrollCompensationScheduled = false
|
pendingScrollCompensationScheduled = false
|
||||||
pendingScrollCompensations = new Map()
|
pendingScrollCompensations = new Map()
|
||||||
pendingAutoPin = false
|
pendingAutoPin = false
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
|
|
||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
pendingActiveScroll = false
|
pendingActiveScroll = false
|
||||||
@@ -718,7 +745,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (autoScroll()) scheduleAnchorScroll(true)
|
if (autoScroll()) {
|
||||||
|
scheduleAutoPinToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (anchorLock() && !autoScroll()) {
|
||||||
|
scheduleAnchorCorrection()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Drop anchor lock if the anchored key is removed.
|
// Drop anchor lock if the anchored key is removed.
|
||||||
@@ -825,6 +858,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
scrollCompensationGen += 1
|
scrollCompensationGen += 1
|
||||||
pendingScrollCompensationScheduled = false
|
pendingScrollCompensationScheduled = false
|
||||||
pendingScrollCompensations = new Map()
|
pendingScrollCompensations = new Map()
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
clearScrollToBottomFrames()
|
clearScrollToBottomFrames()
|
||||||
if (detachScrollIntentListeners) {
|
if (detachScrollIntentListeners) {
|
||||||
detachScrollIntentListeners()
|
detachScrollIntentListeners()
|
||||||
@@ -888,35 +922,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const anchorId = () => getAnchorId(key())
|
const anchorId = () => getAnchorId(key())
|
||||||
const overscanPx = props.overscanPx ?? 800
|
const overscanPx = props.overscanPx ?? 800
|
||||||
const suspendMeasurements = () => measurementsSuspended() || !isActive()
|
const suspendMeasurements = () => measurementsSuspended() || !isActive()
|
||||||
const [measureElement, setMeasureElement] = createSignal<HTMLElement | undefined>()
|
const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll()
|
||||||
const [contentRenderVersion, setContentRenderVersion] = createSignal(0)
|
|
||||||
let pendingContentRenderFrame: number | null = null
|
|
||||||
|
|
||||||
const notifyItemRendered = () => {
|
|
||||||
if (pendingContentRenderFrame !== null) return
|
|
||||||
pendingContentRenderFrame = requestAnimationFrame(() => {
|
|
||||||
pendingContentRenderFrame = null
|
|
||||||
setContentRenderVersion((prev) => prev + 1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (pendingContentRenderFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingContentRenderFrame)
|
|
||||||
pendingContentRenderFrame = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualItem
|
<VirtualItem
|
||||||
id={anchorId()}
|
id={anchorId()}
|
||||||
cacheKey={key()}
|
cacheKey={key()}
|
||||||
scrollContainer={scrollElement}
|
scrollContainer={scrollElement}
|
||||||
threshold={overscanPx}
|
threshold={overscanPx}
|
||||||
measureElement={measureElement}
|
|
||||||
contentRenderVersion={contentRenderVersion}
|
|
||||||
placeholderClass="message-stream-placeholder"
|
placeholderClass="message-stream-placeholder"
|
||||||
virtualizationEnabled={virtualizationEnabled}
|
virtualizationEnabled={itemVirtualizationEnabled}
|
||||||
suspendMeasurements={suspendMeasurements}
|
suspendMeasurements={suspendMeasurements}
|
||||||
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
|
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
|
||||||
const delta = nextHeight - previousHeight
|
const delta = nextHeight - previousHeight
|
||||||
@@ -943,13 +957,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
scheduleScrollCompensation(key(), delta)
|
scheduleScrollCompensation(key(), delta)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>{() => props.renderItem(item(), index)}</VirtualItem>
|
||||||
{() =>
|
|
||||||
props.renderItem(item(), index, {
|
|
||||||
registerMeasureElement: (element) => setMeasureElement(element ?? undefined),
|
|
||||||
notifyItemRendered,
|
|
||||||
})}
|
|
||||||
</VirtualItem>
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Index>
|
</Index>
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ const sizeCache = new Map<string, number>()
|
|||||||
const DEFAULT_MARGIN_PX = 600
|
const DEFAULT_MARGIN_PX = 600
|
||||||
const MIN_PLACEHOLDER_HEIGHT = 400
|
const MIN_PLACEHOLDER_HEIGHT = 400
|
||||||
const VISIBILITY_BUFFER_PX = 0
|
const VISIBILITY_BUFFER_PX = 0
|
||||||
const SETTLED_HEIGHT_EPSILON_PX = 1
|
|
||||||
const SETTLED_HEIGHT_FRAMES = 2
|
|
||||||
|
|
||||||
type ObserverRoot = Element | Document | null
|
type ObserverRoot = Element | Document | null
|
||||||
|
|
||||||
@@ -162,8 +160,6 @@ interface VirtualItemProps {
|
|||||||
scrollContainer?: Accessor<HTMLElement | undefined | null>
|
scrollContainer?: Accessor<HTMLElement | undefined | null>
|
||||||
threshold?: number
|
threshold?: number
|
||||||
minPlaceholderHeight?: number
|
minPlaceholderHeight?: number
|
||||||
measureElement?: Accessor<HTMLElement | undefined | null>
|
|
||||||
contentRenderVersion?: Accessor<number>
|
|
||||||
class?: string
|
class?: string
|
||||||
contentClass?: string
|
contentClass?: string
|
||||||
placeholderClass?: string
|
placeholderClass?: string
|
||||||
@@ -182,8 +178,6 @@ export interface VirtualItemHeightChangeMeta {
|
|||||||
wasHidden: boolean
|
wasHidden: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type VisibleSettlingMode = "hidden-to-visible" | "visible-rerender"
|
|
||||||
|
|
||||||
export default function VirtualItem(props: VirtualItemProps) {
|
export default function VirtualItem(props: VirtualItemProps) {
|
||||||
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
|
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
|
||||||
const cachedHeight = sizeCache.get(props.cacheKey)
|
const cachedHeight = sizeCache.get(props.cacheKey)
|
||||||
@@ -196,8 +190,6 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
// When content first mounts, onHeightChange deltas should reflect the DOM's
|
// When content first mounts, onHeightChange deltas should reflect the DOM's
|
||||||
// placeholder height (not 0), otherwise scroll compensation can overshoot.
|
// placeholder height (not 0), otherwise scroll compensation can overshoot.
|
||||||
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
|
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
|
||||||
const [settlingMode, setSettlingMode] = createSignal<VisibleSettlingMode | null>(null)
|
|
||||||
const isSettlingVisible = createMemo(() => settlingMode() !== null)
|
|
||||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
||||||
let pendingVisibility: boolean | null = null
|
let pendingVisibility: boolean | null = null
|
||||||
let visibilityFrame: number | null = null
|
let visibilityFrame: number | null = null
|
||||||
@@ -214,11 +206,6 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const queueVisibility = (nextValue: boolean) => {
|
const queueVisibility = (nextValue: boolean) => {
|
||||||
if (nextValue && !isIntersecting()) {
|
|
||||||
setSettlingMode("hidden-to-visible")
|
|
||||||
} else if (!nextValue) {
|
|
||||||
setSettlingMode(null)
|
|
||||||
}
|
|
||||||
pendingVisibility = nextValue
|
pendingVisibility = nextValue
|
||||||
if (visibilityFrame !== null) return
|
if (visibilityFrame !== null) return
|
||||||
visibilityFrame = requestAnimationFrame(() => {
|
visibilityFrame = requestAnimationFrame(() => {
|
||||||
@@ -231,22 +218,18 @@ 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 measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||||
|
const forceVisible = () => Boolean(props.forceVisible?.())
|
||||||
const shouldHideContent = createMemo(() => {
|
const shouldHideContent = createMemo(() => {
|
||||||
if (props.forceVisible?.()) return false
|
if (forceVisible()) return false
|
||||||
if (!virtualizationEnabled()) return false
|
if (!virtualizationEnabled()) return false
|
||||||
return !isIntersecting()
|
return !isIntersecting()
|
||||||
})
|
})
|
||||||
const shouldHideMountedContent = createMemo(() => shouldHideContent() || settlingMode() === "hidden-to-visible")
|
|
||||||
let wrapperRef: HTMLDivElement | undefined
|
let wrapperRef: HTMLDivElement | undefined
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | undefined
|
let resizeObserver: ResizeObserver | undefined
|
||||||
let intersectionCleanup: (() => void) | undefined
|
let intersectionCleanup: (() => void) | undefined
|
||||||
let delayedMeasureFrame: number | null = null
|
|
||||||
let delayedMeasureFrame2: number | null = null
|
|
||||||
let settlingMeasureFrame: number | null = null
|
|
||||||
let settlingStableFrames = 0
|
|
||||||
let settlingLastHeight: number | null = null
|
|
||||||
|
|
||||||
function cleanupResizeObserver() {
|
function cleanupResizeObserver() {
|
||||||
if (resizeObserver) {
|
if (resizeObserver) {
|
||||||
@@ -255,104 +238,15 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearDelayedMeasureFrames() {
|
function scheduleVisibleMeasurements() {
|
||||||
if (delayedMeasureFrame !== null) {
|
|
||||||
cancelAnimationFrame(delayedMeasureFrame)
|
|
||||||
delayedMeasureFrame = null
|
|
||||||
}
|
|
||||||
if (delayedMeasureFrame2 !== null) {
|
|
||||||
cancelAnimationFrame(delayedMeasureFrame2)
|
|
||||||
delayedMeasureFrame2 = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSettlingMeasurementFrame() {
|
|
||||||
if (settlingMeasureFrame !== null) {
|
|
||||||
cancelAnimationFrame(settlingMeasureFrame)
|
|
||||||
settlingMeasureFrame = null
|
|
||||||
}
|
|
||||||
settlingStableFrames = 0
|
|
||||||
settlingLastHeight = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVisibleSettlingMode(): VisibleSettlingMode {
|
|
||||||
return awaitingVisibleMeasurement ? "hidden-to-visible" : "visible-rerender"
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleDelayedVisibleMeasurements() {
|
|
||||||
clearDelayedMeasureFrames()
|
|
||||||
delayedMeasureFrame = requestAnimationFrame(() => {
|
|
||||||
delayedMeasureFrame = null
|
|
||||||
if (shouldHideContent() || measurementsSuspended()) return
|
|
||||||
if (!contentRef) return
|
|
||||||
updateMeasuredHeight()
|
|
||||||
delayedMeasureFrame2 = requestAnimationFrame(() => {
|
|
||||||
delayedMeasureFrame2 = null
|
|
||||||
if (shouldHideContent() || measurementsSuspended()) return
|
|
||||||
if (!contentRef) return
|
|
||||||
updateMeasuredHeight()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitSettledMeasurement(next: number) {
|
|
||||||
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
|
|
||||||
const wasHidden = lastMeasurementWhileHidden
|
|
||||||
awaitingVisibleMeasurement = false
|
|
||||||
lastMeasurementWhileHidden = false
|
|
||||||
setSettlingMode(null)
|
|
||||||
persistMeasurement(next, { source: measurementSource, wasHidden })
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleSettledVisibleMeasurement(mode = getVisibleSettlingMode()) {
|
|
||||||
if (shouldHideContent() || measurementsSuspended()) return
|
|
||||||
if (!contentRef) return
|
|
||||||
setSettlingMode(mode)
|
|
||||||
clearSettlingMeasurementFrame()
|
|
||||||
|
|
||||||
const tick = () => {
|
|
||||||
settlingMeasureFrame = requestAnimationFrame(() => {
|
|
||||||
settlingMeasureFrame = null
|
|
||||||
if (shouldHideContent() || measurementsSuspended()) {
|
|
||||||
setSettlingMode(null)
|
|
||||||
clearSettlingMeasurementFrame()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const measurement = getMeasuredContentRect()
|
|
||||||
if (!measurement) {
|
|
||||||
tick()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const sampledHeight = Math.max(0, Math.round(measurement.rect.height * 2) / 2)
|
|
||||||
const previousSample = settlingLastHeight
|
|
||||||
settlingLastHeight = sampledHeight
|
|
||||||
if (previousSample !== null && Math.abs(sampledHeight - previousSample) <= SETTLED_HEIGHT_EPSILON_PX) {
|
|
||||||
settlingStableFrames += 1
|
|
||||||
} else {
|
|
||||||
settlingStableFrames = 0
|
|
||||||
}
|
|
||||||
if (settlingStableFrames >= SETTLED_HEIGHT_FRAMES) {
|
|
||||||
clearSettlingMeasurementFrame()
|
|
||||||
commitSettledMeasurement(sampledHeight)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tick()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
tick()
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleContentRenderedMeasurements() {
|
|
||||||
if (shouldHideContent() || measurementsSuspended()) return
|
if (shouldHideContent() || measurementsSuspended()) return
|
||||||
if (!contentRef) return
|
if (!contentRef) return
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (shouldHideContent() || measurementsSuspended()) return
|
if (shouldHideContent() || measurementsSuspended()) return
|
||||||
if (!contentRef) return
|
if (!contentRef) return
|
||||||
|
updateMeasuredHeight()
|
||||||
setupResizeObserver()
|
setupResizeObserver()
|
||||||
scheduleSettledVisibleMeasurement(getVisibleSettlingMode())
|
|
||||||
})
|
})
|
||||||
scheduleDelayedVisibleMeasurements()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupIntersectionObserver() {
|
function cleanupIntersectionObserver() {
|
||||||
@@ -362,69 +256,6 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMeasuredContentRect(): { rect: DOMRect } | null {
|
|
||||||
const explicitMeasureElement = props.measureElement?.()
|
|
||||||
if (explicitMeasureElement?.isConnected) {
|
|
||||||
return {
|
|
||||||
rect: explicitMeasureElement.getBoundingClientRect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!contentRef) return null
|
|
||||||
|
|
||||||
const childElements = Array.from(contentRef.children).filter((node): node is HTMLElement => node instanceof HTMLElement)
|
|
||||||
if (childElements.length === 0) {
|
|
||||||
return {
|
|
||||||
rect: contentRef.getBoundingClientRect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let top = Number.POSITIVE_INFINITY
|
|
||||||
let bottom = Number.NEGATIVE_INFINITY
|
|
||||||
let left = Number.POSITIVE_INFINITY
|
|
||||||
let right = Number.NEGATIVE_INFINITY
|
|
||||||
let sawNonZero = false
|
|
||||||
|
|
||||||
for (const child of childElements) {
|
|
||||||
const rect = child.getBoundingClientRect()
|
|
||||||
if (rect.width <= 0 && rect.height <= 0) continue
|
|
||||||
sawNonZero = true
|
|
||||||
if (rect.top < top) top = rect.top
|
|
||||||
if (rect.bottom > bottom) bottom = rect.bottom
|
|
||||||
if (rect.left < left) left = rect.left
|
|
||||||
if (rect.right > right) right = rect.right
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sawNonZero) {
|
|
||||||
return {
|
|
||||||
rect: contentRef.getBoundingClientRect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
rect: {
|
|
||||||
x: left,
|
|
||||||
y: top,
|
|
||||||
top,
|
|
||||||
bottom,
|
|
||||||
left,
|
|
||||||
right,
|
|
||||||
width: Math.max(0, right - left),
|
|
||||||
height: Math.max(0, bottom - top),
|
|
||||||
toJSON: () => ({
|
|
||||||
x: left,
|
|
||||||
y: top,
|
|
||||||
top,
|
|
||||||
bottom,
|
|
||||||
left,
|
|
||||||
right,
|
|
||||||
width: Math.max(0, right - left),
|
|
||||||
height: Math.max(0, bottom - top),
|
|
||||||
}),
|
|
||||||
} as DOMRect,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) {
|
function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) {
|
||||||
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
||||||
return
|
return
|
||||||
@@ -466,11 +297,6 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMeasuredHeight(normalized)
|
setMeasuredHeight(normalized)
|
||||||
if (measurementMeta.isStaleCacheCorrection) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
recheckVisibilityAfterMeasurement()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
|
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,14 +305,16 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
if (measurementsSuspended()) return
|
if (measurementsSuspended()) return
|
||||||
// Prefer subpixel-accurate height for scroll compensation.
|
// Prefer subpixel-accurate height for scroll compensation.
|
||||||
// offsetHeight rounds to integers which can accumulate error.
|
// offsetHeight rounds to integers which can accumulate error.
|
||||||
const measurement = getMeasuredContentRect()
|
const rect = contentRef.getBoundingClientRect()
|
||||||
if (!measurement) return
|
|
||||||
const { rect } = measurement
|
|
||||||
const next = Math.max(0, Math.round(rect.height * 2) / 2)
|
const next = Math.max(0, Math.round(rect.height * 2) / 2)
|
||||||
const currentMeasured = measuredHeight()
|
const currentMeasured = measuredHeight()
|
||||||
if (next === currentMeasured) return
|
|
||||||
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
|
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
|
||||||
const wasHidden = lastMeasurementWhileHidden
|
const wasHidden = lastMeasurementWhileHidden
|
||||||
|
if (measurementSource === "initial-visible-measure") {
|
||||||
|
awaitingVisibleMeasurement = false
|
||||||
|
lastMeasurementWhileHidden = false
|
||||||
|
}
|
||||||
|
if (next === currentMeasured) return
|
||||||
persistMeasurement(next, { source: measurementSource, wasHidden })
|
persistMeasurement(next, { source: measurementSource, wasHidden })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +327,6 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
if (measurementsSuspended()) return
|
if (measurementsSuspended()) return
|
||||||
if (isSettlingVisible()) return
|
|
||||||
updateMeasuredHeight()
|
updateMeasuredHeight()
|
||||||
})
|
})
|
||||||
resizeObserver.observe(contentRef)
|
resizeObserver.observe(contentRef)
|
||||||
@@ -551,17 +378,11 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const rootRect = (targetRoot as Element).getBoundingClientRect()
|
const rootRect = (targetRoot as Element).getBoundingClientRect()
|
||||||
const wrapperRect = wrapperEl.getBoundingClientRect()
|
|
||||||
const visible = shouldRenderByRects({
|
const visible = shouldRenderByRects({
|
||||||
wrapperRect,
|
wrapperRect: wrapperEl.getBoundingClientRect(),
|
||||||
rootRect: { top: rootRect.top, bottom: rootRect.bottom },
|
rootRect: { top: rootRect.top, bottom: rootRect.bottom },
|
||||||
margin,
|
margin,
|
||||||
})
|
})
|
||||||
const collapsedWhileVisible = isIntersecting() && !visible && Math.round(wrapperRect.height) <= 0 && measuredHeight() > 0
|
|
||||||
if (collapsedWhileVisible) {
|
|
||||||
queueVisibility(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
queueVisibility(visible)
|
queueVisibility(visible)
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
@@ -574,37 +395,6 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function recheckVisibilityAfterMeasurement() {
|
|
||||||
if (!wrapperRef) return
|
|
||||||
|
|
||||||
const margin = props.threshold ?? DEFAULT_MARGIN_PX
|
|
||||||
const wrapperRect = wrapperRef.getBoundingClientRect()
|
|
||||||
const targetRoot = props.scrollContainer ? props.scrollContainer() : null
|
|
||||||
|
|
||||||
if (targetRoot && !(targetRoot instanceof Document)) {
|
|
||||||
const rootRect = targetRoot.getBoundingClientRect()
|
|
||||||
const visible = shouldRenderByRects({
|
|
||||||
wrapperRect,
|
|
||||||
rootRect: { top: rootRect.top, bottom: rootRect.bottom },
|
|
||||||
margin,
|
|
||||||
})
|
|
||||||
if (!visible) {
|
|
||||||
queueVisibility(false)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewportRect = getViewportRect()
|
|
||||||
const visible = shouldRenderByRects({
|
|
||||||
wrapperRect,
|
|
||||||
rootRect: viewportRect,
|
|
||||||
margin,
|
|
||||||
})
|
|
||||||
if (!visible) {
|
|
||||||
queueVisibility(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setWrapperRef(element: HTMLDivElement | null) {
|
function setWrapperRef(element: HTMLDivElement | null) {
|
||||||
wrapperRef = element ?? undefined
|
wrapperRef = element ?? undefined
|
||||||
const root = props.scrollContainer ? props.scrollContainer() : null
|
const root = props.scrollContainer ? props.scrollContainer() : null
|
||||||
@@ -628,27 +418,14 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
if (hidden) {
|
if (hidden) {
|
||||||
awaitingVisibleMeasurement = true
|
awaitingVisibleMeasurement = true
|
||||||
lastMeasurementWhileHidden = true
|
lastMeasurementWhileHidden = true
|
||||||
clearDelayedMeasureFrames()
|
|
||||||
clearSettlingMeasurementFrame()
|
|
||||||
setSettlingMode(null)
|
|
||||||
}
|
}
|
||||||
if (hidden || measurementsSuspended()) {
|
if (hidden || measurementsSuspended()) {
|
||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
}
|
}
|
||||||
if (!hidden && !measurementsSuspended() && contentRef) {
|
if (!hidden && !measurementsSuspended() && contentRef) {
|
||||||
queueMicrotask(() => {
|
scheduleVisibleMeasurements()
|
||||||
setupResizeObserver()
|
|
||||||
scheduleSettledVisibleMeasurement(getVisibleSettlingMode())
|
|
||||||
})
|
|
||||||
scheduleDelayedVisibleMeasurements()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
createEffect(() => {
|
|
||||||
const version = props.contentRenderVersion?.()
|
|
||||||
if (version === undefined) return
|
|
||||||
if (version <= 0) return
|
|
||||||
scheduleContentRenderedMeasurements()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -680,15 +457,13 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
cleanupIntersectionObserver()
|
cleanupIntersectionObserver()
|
||||||
clearDelayedMeasureFrames()
|
|
||||||
clearSettlingMeasurementFrame()
|
|
||||||
flushVisibility()
|
flushVisibility()
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
|
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
|
||||||
const contentClass = () => {
|
const contentClass = () => {
|
||||||
const classes = ["virtual-item-content", props.contentClass]
|
const classes = ["virtual-item-content", props.contentClass]
|
||||||
if (shouldHideMountedContent()) {
|
if (shouldHideContent()) {
|
||||||
classes.push("virtual-item-content-hidden")
|
classes.push("virtual-item-content-hidden")
|
||||||
}
|
}
|
||||||
return classes.filter(Boolean).join(" ")
|
return classes.filter(Boolean).join(" ")
|
||||||
@@ -705,7 +480,7 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
class={placeholderClass()}
|
class={placeholderClass()}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: shouldHideMountedContent() ? `${placeholderHeight()}px` : undefined,
|
height: shouldHideContent() ? `${placeholderHeight()}px` : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={setContentRef} class={contentClass()}>
|
<div ref={setContentRef} class={contentClass()}>
|
||||||
|
|||||||
Reference in New Issue
Block a user