From dea2099692fcb54bed2c9b85802856f4a2bab16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 3 Sep 2026 18:45:48 +0200 Subject: [PATCH] fix(ui): pin timeline shifts before paint Complete ordered capped-window shifts in a microtask so Virtua can preserve its measurements without exposing the intermediate appended window to a painted frame. Observe the connected Virtua measurement root and flush followed-content growth from ResizeObserver delivery. This keeps the viewport pinned before paint while preserving escaped user anchors, restore authority, hold behavior, and the 200-message memory bound. Validated with UI typechecking, 68 focused timeline and pagination tests, a release Tauri build, deterministic native resize probes, inactive-tab anchor restoration, and prolonged Developer Mode mutation monitoring. --- .../ui/src/components/virtual-follow-list.tsx | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 0d35f2d3c..681ae94fc 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -157,7 +157,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { let localBottomPinSequence = 0 let programmaticScrollUntil = 0 let virtualItemKeys = virtualItems().map((item, index) => props.getKey(item, index)) - let pendingWindowShiftFrame: number | null = null + let windowShiftGeneration = 0 + let virtualContentResizeObserver: ResizeObserver | null = null + let observedVirtualContent: HTMLElement | null = null function invalidateScrollRestore() { restoreToken.invalidate() @@ -368,6 +370,27 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { return } itemElements.set(key, element) + queueMicrotask(() => { + if (itemElements.get(key) !== element || !element.isConnected) return + // Solid calls refs before inserting the node. Once connected, Virtua's + // measured-height root is the parent of its item wrapper. + observeVirtualContent(element.parentElement?.parentElement) + }) + } + + function observeVirtualContent(element: HTMLElement | null | undefined) { + if (!element || element === observedVirtualContent || typeof ResizeObserver === "undefined") return + if (!virtualContentResizeObserver) { + virtualContentResizeObserver = new ResizeObserver(() => { + if (!isActive()) return + if (pendingContentRenderedFrame !== null) cancelAnimationFrame(pendingContentRenderedFrame) + pendingContentRenderedFrame = null + flushContentRendered() + }) + } + if (observedVirtualContent) virtualContentResizeObserver.unobserve(observedVirtualContent) + observedVirtualContent = element + virtualContentResizeObserver.observe(element) } function maybeEscapeForHoldTrigger() { @@ -874,8 +897,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { return { items, keys: items.map((item, index) => props.getKey(item, index)) } }, ({ items: nextItems, keys: nextItemKeys }) => { - if (pendingWindowShiftFrame !== null) cancelAnimationFrame(pendingWindowShiftFrame) - pendingWindowShiftFrame = null + const shiftGeneration = ++windowShiftGeneration const change = classifyVirtualItemKeyChange(virtualItemKeys, nextItemKeys) if (change.shiftedStartCount > 0) { @@ -883,8 +905,8 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { setShiftVirtualItems(false) setVirtualItems([...virtualItems(), ...nextItems.slice(retainedCount)]) virtualItemKeys = [...virtualItemKeys, ...nextItemKeys.slice(retainedCount)] - pendingWindowShiftFrame = requestAnimationFrame(() => { - pendingWindowShiftFrame = null + queueMicrotask(() => { + if (shiftGeneration !== windowShiftGeneration) return setShiftVirtualItems(true) setVirtualItems(nextItems.slice()) virtualItemKeys = nextItemKeys @@ -915,8 +937,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { pendingViewportHeightDelta = 0 dispatchFollowEvent({ type: "reset", follow: initialAutoScroll() }) pendingInitialScroll = true - if (pendingWindowShiftFrame !== null) cancelAnimationFrame(pendingWindowShiftFrame) - pendingWindowShiftFrame = null + windowShiftGeneration += 1 const items = props.items() setShiftVirtualItems(false) setVirtualItems(items.slice()) @@ -984,7 +1005,10 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { clearExplicitBottomPin() if (pendingContentRenderedFrame !== null) cancelAnimationFrame(pendingContentRenderedFrame) if (pendingExplicitBottomPinFrame !== null) cancelAnimationFrame(pendingExplicitBottomPinFrame) - if (pendingWindowShiftFrame !== null) cancelAnimationFrame(pendingWindowShiftFrame) + windowShiftGeneration += 1 + virtualContentResizeObserver?.disconnect() + virtualContentResizeObserver = null + observedVirtualContent = null detachScrollIntentListeners?.() })