From ffa0f61dd0a1849a5c6474f597e886e3052adb28 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sat, 22 Aug 2026 10:02:49 +0800 Subject: [PATCH 1/6] =?UTF-8?q?refactor(virtual):=20=E6=94=B6=E7=BC=96=20v?= =?UTF-8?q?irtual-core=20fork,=E6=BB=9A=E5=8A=A8=E5=86=99=E5=85=A5?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E5=8C=96=E5=B9=B6=E6=96=B0=E5=A2=9E=20origin?= =?UTF-8?q?=20=E9=94=9A=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 滚动位置回归 DOM 单一事实源,根治"滚轮滑动出现空白、再滚一下才恢复": - 收编 @tanstack/virtual-core@3.17.4 为 workspace 包(pnpm override workspace:*),源码/类型/测试就地管理,registry 的 react-virtual 包 透明复用; - Phase 1 写入事务化:每次程序化 scrollTo 一帧后读回校验,被合成器 吞掉的写入以 DOM 真值回滚镜像并重算区间(WKWebView 滚轮手势期间 主线程写入会被静默拒绝,镜像偏离导致渲染窗口错位、视口顶部露白); - Phase 2 origin 偏移锚定:视口上方估高修正与 prepend 键锚定改为吸收 进布局原点,可见行 DOM 位置零移动、滚动中零写入,债务在空闲/超限/ 近顶时由单次受校验写入结算; - Phase 3 方向性像素 overscan(粘滞于最近滚动方向,免除落定churn) 与 dev 态镜像-DOM 不变量断言; - 13 个核心回归测试覆盖吞写自愈、origin 吸收、rebase 触发与自愈闭环。 Co-authored-by: Cursor --- crates/virtual-core/LICENSE | 21 + crates/virtual-core/package.json | 22 + crates/virtual-core/src/index.ts | 2247 +++++++++++++++++ crates/virtual-core/src/lazy-measurements.ts | 44 + crates/virtual-core/src/process-env.d.ts | 6 + crates/virtual-core/src/utils.ts | 109 + .../test/directional-overscan.test.mjs | 40 + crates/virtual-core/test/helpers/harness.mjs | 141 ++ .../virtual-core/test/helpers/load-core.mjs | 44 + .../test/origin-anchoring.test.mjs | 164 ++ .../virtual-core/test/write-verify.test.mjs | 74 + crates/virtual-core/tsconfig.json | 19 + crates/virtual-core/types/index.d.ts | 209 ++ crates/virtual-core/types/utils.d.ts | 15 + package.json | 3 + pnpm-lock.yaml | 14 +- pnpm-workspace.yaml | 1 + scripts/check.mjs | 2 + 18 files changed, 3169 insertions(+), 6 deletions(-) create mode 100644 crates/virtual-core/LICENSE create mode 100644 crates/virtual-core/package.json create mode 100644 crates/virtual-core/src/index.ts create mode 100644 crates/virtual-core/src/lazy-measurements.ts create mode 100644 crates/virtual-core/src/process-env.d.ts create mode 100644 crates/virtual-core/src/utils.ts create mode 100644 crates/virtual-core/test/directional-overscan.test.mjs create mode 100644 crates/virtual-core/test/helpers/harness.mjs create mode 100644 crates/virtual-core/test/helpers/load-core.mjs create mode 100644 crates/virtual-core/test/origin-anchoring.test.mjs create mode 100644 crates/virtual-core/test/write-verify.test.mjs create mode 100644 crates/virtual-core/tsconfig.json create mode 100644 crates/virtual-core/types/index.d.ts create mode 100644 crates/virtual-core/types/utils.d.ts diff --git a/crates/virtual-core/LICENSE b/crates/virtual-core/LICENSE new file mode 100644 index 000000000..1869e21fc --- /dev/null +++ b/crates/virtual-core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-present Tanner Linsley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/virtual-core/package.json b/crates/virtual-core/package.json new file mode 100644 index 000000000..e8f42132d --- /dev/null +++ b/crates/virtual-core/package.json @@ -0,0 +1,22 @@ +{ + "name": "@tanstack/virtual-core", + "version": "3.17.4", + "private": true, + "description": "Vendored LiveAgent fork of @tanstack/virtual-core. Resolved workspace-wide via the pnpm override in the root package.json so both the app code and the registry @tanstack/react-virtual wrapper consume this source.", + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./src/index.ts" + } + }, + "files": ["src", "types", "LICENSE"], + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "node ../../scripts/run-node-tests.mjs test" + }, + "devDependencies": { + "typescript": "~7.0.2" + } +} diff --git a/crates/virtual-core/src/index.ts b/crates/virtual-core/src/index.ts new file mode 100644 index 000000000..bf0cc6073 --- /dev/null +++ b/crates/virtual-core/src/index.ts @@ -0,0 +1,2247 @@ +import { createLazyMeasurementsView } from './lazy-measurements' +import { approxEqual, debounce, memo, notUndefined } from './utils' + +// Browser-aware iOS detection. Programmatic `scrollTo`/`scrollTop` writes +// during a momentum-scroll cancel the momentum on iOS WebKit, so we defer +// scroll-position adjustments triggered by mid-scroll resizes until the +// scroll settles. SSR-safe (returns false when navigator is unavailable). +let _isIOSResult: boolean | undefined +const isIOSWebKit = (): boolean => { + if (_isIOSResult !== undefined) return _isIOSResult + if (typeof navigator === 'undefined') return (_isIOSResult = false) + if (/iP(hone|od|ad)/.test(navigator.userAgent)) return (_isIOSResult = true) + // iPadOS 13+ reports as MacIntel; touch-points distinguishes it from desktop. + const mtp = (navigator as Navigator & { maxTouchPoints?: number }) + .maxTouchPoints + return (_isIOSResult = + navigator.platform === 'MacIntel' && mtp !== undefined && mtp > 0) +} + +// Test hook: reset the iOS detection cache. Not exported. +export const _resetIOSDetectionForTests = () => { + _isIOSResult = undefined +} + +export { approxEqual, debounce, memo, notUndefined } from './utils' +export type { NoInfer, PartialKeys } from './utils' + +// + +type ScrollDirection = 'forward' | 'backward' + +type ScrollAlignment = 'start' | 'center' | 'end' | 'auto' + +type ScrollBehavior = 'auto' | 'smooth' | 'instant' + +type ScrollAnchor = 'start' | 'end' + +// How estimate→measured corrections for content above the viewport keep the +// visible rows stable. +// - 'offset' (default, upstream behavior): write the delta to scrollTop. +// Every correction is a programmatic scroll racing the user's gesture on +// compositor-scrolled viewports. +// - 'origin': absorb the delta into a layout origin baked into every row +// position — visible rows keep their exact DOM position, scrollTop is +// never touched mid-scroll. The accumulated origin debt is settled by a +// single verified scrollTop write ("rebase") when scrolling is idle, when +// the debt exceeds its budget, or before the viewport can reach the +// mis-positioned zone near the top. +type ScrollAnchoringMode = 'offset' | 'origin' + +type FollowOnAppend = boolean | ScrollBehavior + +export interface ScrollToOptions { + align?: ScrollAlignment + behavior?: ScrollBehavior +} + +type ScrollToOffsetOptions = ScrollToOptions + +type ScrollToIndexOptions = ScrollToOptions + +type ScrollToEndOptions = Pick + +export interface Range { + startIndex: number + endIndex: number + overscan: number + count: number +} + +type Key = number | string | bigint + +export interface VirtualItem { + key: Key + index: number + start: number + end: number + size: number + lane: number +} + +export interface Rect { + width: number + height: number +} + +// + +const getRect = (element: HTMLElement): Rect => { + const { offsetWidth, offsetHeight } = element + return { width: offsetWidth, height: offsetHeight } +} + +export const defaultKeyExtractor = (index: number) => index + +export const defaultRangeExtractor = (range: Range) => { + const start = Math.max(range.startIndex - range.overscan, 0) + const end = Math.min(range.endIndex + range.overscan, range.count - 1) + const len = end - start + 1 + + const arr = new Array(len) + for (let i = 0; i < len; i++) { + arr[i] = start + i + } + return arr +} + +export const observeElementRect = ( + instance: Virtualizer, + cb: (rect: Rect) => void, +) => { + const element = instance.scrollElement + if (!element) { + return + } + const targetWindow = instance.targetWindow + if (!targetWindow) { + return + } + + const handler = (rect: Rect) => { + const { width, height } = rect + cb({ width: Math.round(width), height: Math.round(height) }) + } + + handler(getRect(element as unknown as HTMLElement)) + + if (!targetWindow.ResizeObserver) { + return () => {} + } + + const observer = new targetWindow.ResizeObserver((entries) => { + const run = () => { + const entry = entries[0] + if (entry?.borderBoxSize) { + const box = entry.borderBoxSize[0] + if (box) { + handler({ width: box.inlineSize, height: box.blockSize }) + return + } + } + handler(getRect(element as unknown as HTMLElement)) + } + + instance.options.useAnimationFrameWithResizeObserver + ? requestAnimationFrame(run) + : run() + }) + + observer.observe(element, { box: 'border-box' }) + + return () => { + observer.unobserve(element) + } +} + +const addEventListenerOptions = { + passive: true, +} + +export const observeWindowRect = ( + instance: Virtualizer, + cb: (rect: Rect) => void, +) => { + const element = instance.scrollElement + if (!element) { + return + } + + const handler = () => { + cb({ width: element.innerWidth, height: element.innerHeight }) + } + handler() + + element.addEventListener('resize', handler, addEventListenerOptions) + + return () => { + element.removeEventListener('resize', handler) + } +} + +const supportsScrollend = + typeof window == 'undefined' ? true : 'onscrollend' in window + +type ObserveOffsetCallBack = (offset: number, isScrolling: boolean) => void + +// Shared core: both element and window variants attach scroll/scrollend +// listeners with the same lifecycle; they only differ in how to read the +// current offset from the scroll target. +const observeOffset = ( + instance: Virtualizer, + cb: ObserveOffsetCallBack, + readOffset: (target: T) => number, +) => { + const element = instance.scrollElement + if (!element) { + return + } + const targetWindow = instance.targetWindow + if (!targetWindow) { + return + } + + const registerScrollendEvent = + instance.options.useScrollendEvent && supportsScrollend + + let offset = 0 + const fallback = registerScrollendEvent + ? null + : debounce( + targetWindow, + () => cb(offset, false), + instance.options.isScrollingResetDelay, + ) + + const createHandler = (isScrolling: boolean) => () => { + offset = readOffset(element) + fallback?.() + cb(offset, isScrolling) + } + const handler = createHandler(true) + const endHandler = createHandler(false) + + element.addEventListener('scroll', handler, addEventListenerOptions) + if (registerScrollendEvent) { + element.addEventListener('scrollend', endHandler, addEventListenerOptions) + } + return () => { + element.removeEventListener('scroll', handler) + if (registerScrollendEvent) { + element.removeEventListener('scrollend', endHandler) + } + } +} + +export const observeElementOffset = ( + instance: Virtualizer, + cb: ObserveOffsetCallBack, +) => + observeOffset(instance, cb, (el) => { + const { horizontal, isRtl } = instance.options + return horizontal ? el.scrollLeft * ((isRtl && -1) || 1) : el.scrollTop + }) + +export const observeWindowOffset = ( + instance: Virtualizer, + cb: ObserveOffsetCallBack, +) => + observeOffset(instance, cb, (win) => + instance.options.horizontal ? win.scrollX : win.scrollY, + ) + +export const measureElement = ( + element: TItemElement, + entry: ResizeObserverEntry | undefined, + instance: Virtualizer, +) => { + // When useCachedMeasurements is enabled, return the cached size + // (or estimateSize as fallback) instead of measuring the DOM. + if (instance.options.useCachedMeasurements) { + const index = instance.indexFromElement(element) + const key = instance.options.getItemKey(index) + return ( + instance.itemSizeCache.get(key) ?? instance.options.estimateSize(index) + ) + } + + if (entry?.borderBoxSize) { + const box = entry.borderBoxSize[0] + if (box) { + const size = Math.round( + box[instance.options.horizontal ? 'inlineSize' : 'blockSize'], + ) + return size + } + } + + // When called without a ResizeObserverEntry (sync measurement path), + // return the previously measured size if available. This avoids a + // synchronous layout read (offsetWidth/offsetHeight) on re-renders. + // The ResizeObserver is already observing the element and will deliver + // the accurate size asynchronously if it changed. + // Users who need synchronous DOM reads can provide a custom measureElement. + if (!entry) { + const index = instance.indexFromElement(element) + const key = instance.options.getItemKey(index) + const cachedSize = instance.itemSizeCache.get(key) + if (cachedSize !== undefined) { + return cachedSize + } + } + + return (element as unknown as HTMLElement)[ + instance.options.horizontal ? 'offsetWidth' : 'offsetHeight' + ] +} + +const scrollWithAdjustments = ( + offset: number, + { + adjustments = 0, + behavior, + }: { adjustments?: number; behavior?: ScrollBehavior }, + instance: Virtualizer, +) => { + instance.scrollElement?.scrollTo?.({ + [instance.options.horizontal ? 'left' : 'top']: offset + adjustments, + behavior, + }) +} + +export const windowScroll: ( + offset: number, + options: { adjustments?: number; behavior?: ScrollBehavior }, + instance: Virtualizer, +) => void = scrollWithAdjustments + +export const elementScroll: ( + offset: number, + options: { adjustments?: number; behavior?: ScrollBehavior }, + instance: Virtualizer, +) => void = scrollWithAdjustments + +type LaneAssignmentMode = 'estimate' | 'measured' + +export interface VirtualizerOptions< + TScrollElement extends Element | Window, + TItemElement extends Element, +> { + // Required from the user + count: number + getScrollElement: () => TScrollElement | null + estimateSize: (index: number) => number + + // Required from the framework adapter (but can be overridden) + scrollToFn: ( + offset: number, + options: { adjustments?: number; behavior?: ScrollBehavior }, + instance: Virtualizer, + ) => void + observeElementRect: ( + instance: Virtualizer, + cb: (rect: Rect) => void, + ) => void | (() => void) + observeElementOffset: ( + instance: Virtualizer, + cb: ObserveOffsetCallBack, + ) => void | (() => void) + // Optional + debug?: boolean + initialRect?: Rect + onChange?: ( + instance: Virtualizer, + sync: boolean, + ) => void + measureElement?: ( + element: TItemElement, + entry: ResizeObserverEntry | undefined, + instance: Virtualizer, + ) => number + overscan?: number + horizontal?: boolean + paddingStart?: number + paddingEnd?: number + scrollPaddingStart?: number + scrollPaddingEnd?: number + initialOffset?: number | (() => number) + getItemKey?: (index: number) => Key + rangeExtractor?: (range: Range) => Array + scrollMargin?: number + gap?: number + indexAttribute?: string + initialMeasurementsCache?: Array + lanes?: number + anchorTo?: ScrollAnchor + followOnAppend?: FollowOnAppend + scrollAnchoring?: ScrollAnchoringMode + // Extends the visible window by this many pixels in the direction the + // user is scrolling (sticky to the last known direction), so + // compositor-async scrolling has pre-rendered content to reveal before + // the main thread catches up. 0 disables. + directionalOverscanPx?: number + scrollEndThreshold?: number + isScrollingResetDelay?: number + useScrollendEvent?: boolean + enabled?: boolean + isRtl?: boolean + useAnimationFrameWithResizeObserver?: boolean + laneAssignmentMode?: LaneAssignmentMode + useCachedMeasurements?: boolean +} + +type ScrollState = { + // what we want + index: number | null + align: ScrollAlignment + behavior: ScrollBehavior + + // lifecycle + startedAt: number + + // target tracking + lastTargetOffset: number + + // settling + stableFrames: number +} + +type PendingScrollAnchor = [ + key: Key | null, + offset: number, + followOnAppend: ScrollBehavior | null, + anchorDelta: number, +] + +export class Virtualizer< + TScrollElement extends Element | Window, + TItemElement extends Element, +> { + private unsubs: Array void)> = [] + options!: Required> + scrollElement: TScrollElement | null = null + targetWindow: (Window & typeof globalThis) | null = null + isScrolling = false + private scrollState: ScrollState | null = null + measurementsCache: Array = [] + // Flat backing store for the lanes===1 fast path: [start_0, size_0, start_1, size_1, ...]. + // null until the first single-lane build; reused (and grown) across rebuilds. + private _flatMeasurements: Float64Array | null = null + itemSizeCache = new Map() + private itemSizeCacheVersion = 0 + private laneAssignments = new Map() // index → lane cache + // Earliest index dirtied since last getMeasurements() rebuild, or null. + private pendingMin: number | null = null + private prevLanes: number | undefined = undefined + private lanesChangedFlag = false + private lanesSettling = false + private pendingScrollAnchor: PendingScrollAnchor | null = null + scrollRect: Rect | null = null + scrollOffset: number | null = null + scrollDirection: ScrollDirection | null = null + // Sticky copy of the last non-null scrollDirection; feeds the directional + // overscan so the extended window doesn't collapse (and churn row mounts) + // every time a scroll settles. + private lastScrollDirection: ScrollDirection | null = null + private scrollAdjustments = 0 + // 'origin' scroll anchoring: accumulated layout shift (px) baked into + // every row position via getMeasurements. Above-viewport size corrections + // subtract their delta here instead of writing scrollTop; a rebase settles + // the debt back to 0 with one verified write at a safe moment. + private originOffset = 0 + // Sum of size-change deltas above-viewport that were skipped during + // iOS momentum scroll (writing scrollTop mid-momentum cancels it). + // Flushed in a single scrollTo when iOS is fully settled. + private _iosDeferredAdjustment = 0 + // Touch state. iOS WebKit cancels momentum when scrollTop is written, so + // we defer adjustments not only during `isScrolling` but also through the + // touchstart→touchend window (active drag) and a short tail after + // touchend (early-momentum window — iOS only fires touch events once at + // the start of momentum, so we use a timer rather than another event). + private _iosTouching = false + private _iosJustTouchEnded = false + private _iosTouchEndTimerId: number | null = null + // Subpixel reconciliation. Safari (and Chrome/Firefox under certain DPRs) + // round scrollTop/scrollLeft writes to integer pixels. If we wrote 12345.5 + // but the browser reports back 12346, the next reconcileScroll sees a + // "target changed" and re-fires scrollTo — a feedback loop that the + // approxEqual(<1.01) tolerance otherwise absorbs as a workaround. + // By remembering the intended value of our most-recent self-driven + // scrollTo, we can match the browser's rounded read back to the intended + // value when the diff is < 1.5 px, distinguishing it from a real user + // scroll. The +0.5 over Math.abs lets us also absorb the +1 / -1 cases. + private _intendedScrollOffset: number | null = null + // Write-landing verification. Compositor-scrolled viewports (WKWebView on + // macOS/iOS during an active wheel/touch gesture) can silently reject a + // main-thread scrollTo. `applyScrollAdjustment` eagerly folds the delta + // into `scrollOffset` for same-tick coherence, so a swallowed write leaves + // the mirror diverged from the DOM: ranges are computed for a window the + // viewport never reached and the visible viewport shows an unrendered + // (blank) band until the next user scroll re-syncs. One frame after every + // self-driven write we read the DOM back; if no scroll event consumed the + // intent and the DOM disagrees, the DOM wins: roll the mirror back and + // recompute. Scroll position has exactly one source of truth — the DOM. + private _writeVerifyRafId: number | null = null + shouldAdjustScrollPositionOnItemSizeChange: + | undefined + | (( + item: VirtualItem, + delta: number, + instance: Virtualizer, + ) => boolean) + elementsCache = new Map() + private now = () => this.targetWindow?.performance?.now?.() ?? Date.now() + private observer = (() => { + let _ro: ResizeObserver | null = null + + const get = () => { + if (_ro) { + return _ro + } + + if (!this.targetWindow || !this.targetWindow.ResizeObserver) { + return null + } + + return (_ro = new this.targetWindow.ResizeObserver((entries) => { + entries.forEach((entry) => { + const run = () => { + const node = entry.target as TItemElement + const index = this.indexFromElement(node) + + if (!node.isConnected) { + this.observer.unobserve(node) + // Find the cache entry pointing to this exact node and remove + // it. We can't call getItemKey(index) here because items may + // have been removed since this node was rendered — the index + // could be stale and out-of-bounds in the user's data array + // (regression test in e2e/.../stale-index.spec.ts, fix #1148). + // The === comparison naturally handles the React-replaced- + // a-node-for-the-same-key case: that entry now points to a + // different node, so this loop won't match. + for (const [cacheKey, cachedNode] of this.elementsCache) { + if (cachedNode === node) { + this.elementsCache.delete(cacheKey) + break + } + } + return + } + + if (this.shouldMeasureDuringScroll(index)) { + this.resizeItem( + index, + this.options.measureElement(node, entry, this), + ) + } + } + this.options.useAnimationFrameWithResizeObserver + ? requestAnimationFrame(run) + : run() + }) + })) + } + + return { + disconnect: () => { + get()?.disconnect() + _ro = null + }, + observe: (target: Element) => + get()?.observe(target, { box: 'border-box' }), + unobserve: (target: Element) => get()?.unobserve(target), + } + })() + range: { startIndex: number; endIndex: number } | null = null + + constructor(opts: VirtualizerOptions) { + this.setOptions(opts) + } + + setOptions = (opts: VirtualizerOptions) => { + // Skip `{...defaults, ...opts}` because explicit `undefined` values in + // opts would override defaults with `undefined`. + const merged = { + debug: false, + initialOffset: 0, + overscan: 1, + paddingStart: 0, + paddingEnd: 0, + scrollPaddingStart: 0, + scrollPaddingEnd: 0, + horizontal: false, + getItemKey: defaultKeyExtractor, + rangeExtractor: defaultRangeExtractor, + onChange: () => {}, + measureElement, + initialRect: { width: 0, height: 0 }, + scrollMargin: 0, + gap: 0, + indexAttribute: 'data-index', + initialMeasurementsCache: [], + lanes: 1, + anchorTo: 'start', + followOnAppend: false, + scrollAnchoring: 'offset', + directionalOverscanPx: 0, + scrollEndThreshold: 1, + isScrollingResetDelay: 150, + enabled: true, + isRtl: false, + useScrollendEvent: false, + useAnimationFrameWithResizeObserver: false, + laneAssignmentMode: 'estimate', + useCachedMeasurements: false, + } as unknown as Required> + + for (const key in opts) { + const v = (opts as any)[key] + if (v !== undefined) (merged as any)[key] = v + } + + const prevOptions = this.options as + | Required> + | undefined + let anchor: [Key, number] | null = null + let followOnAppend: ScrollBehavior | null = null + let edgeKeysChanged = false + + if ( + prevOptions !== undefined && + prevOptions.enabled && + merged.enabled && + merged.anchorTo === 'end' && + this.scrollElement !== null + ) { + const prevCount = prevOptions.count + const nextCount = merged.count + const measurements = this.getMeasurements() + const prevFirstKey = + prevCount > 0 + ? (measurements[0]?.key ?? prevOptions.getItemKey(0)) + : null + const prevLastKey = + prevCount > 0 + ? (measurements[prevCount - 1]?.key ?? + prevOptions.getItemKey(prevCount - 1)) + : null + const didCountChange = nextCount !== prevCount + const didEdgeKeysChange = + didCountChange || + (prevCount > 0 && + nextCount > 0 && + (merged.getItemKey(0) !== prevFirstKey || + merged.getItemKey(nextCount - 1) !== prevLastKey)) + + if (didEdgeKeysChange) { + edgeKeysChanged = true + const item = + prevCount > 0 + ? (this.getVirtualItemForOffset(this.getScrollOffset()) ?? + measurements[0]) + : null + + if (item) { + anchor = [item.key, this.getScrollOffset() - item.start] + } + + const behavior = + merged.followOnAppend === true + ? 'auto' + : merged.followOnAppend || null + + if ( + behavior && + nextCount > prevCount && + this.isAtEnd(prevOptions.scrollEndThreshold) && + (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey) + ) { + followOnAppend = behavior + } + } + } + + this.options = merged + + // When edge keys changed (prepend, trim, reorder, etc.) the key→index + // mapping has shifted. Force a full measurement rebuild so the anchor + // resolution below reads positions from the new layout, not the stale + // memoised cache. Without this, a stable `getItemKey` reference + + // unchanged `count` would let getMeasurements() return the old layout. + if (edgeKeysChanged) { + this.pendingMin = 0 + this.itemSizeCacheVersion++ + } + + // Eagerly adjust scrollOffset so the virtualizer computes the correct + // visible range during the current render pass — before _willUpdate + // syncs the DOM scroll position in a layout effect. Without this, + // the virtualizer would render the wrong items for one frame (the + // estimate-based positions are stale) and then correct in the next + // frame, producing a visible "jump" on prepend with dynamic sizes. + let anchorResolved = false + let anchorDelta = 0 + if (anchor && this.scrollOffset !== null) { + const [anchorKey, anchorOffset] = anchor + const newMeasurements = this.getMeasurements() + const { count, getItemKey } = this.options + let idx = 0 + while (idx < count && getItemKey(idx) !== anchorKey) { + idx++ + } + if (idx < count) { + const anchorItem = newMeasurements[idx] + if (anchorItem) { + const newOffset = anchorItem.start + anchorOffset + if (newOffset !== this.scrollOffset) { + if (merged.scrollAnchoring === 'origin') { + // Shift the layout instead of the viewport: bake the delta + // into the origin so the anchor row stays under the unchanged + // scrollOffset. No mirror mutation, no DOM write to race a + // gesture — prepends and reorders become pure layout events. + this.originOffset += this.scrollOffset - newOffset + this.pendingMin = 0 + this.itemSizeCacheVersion++ + } else { + anchorDelta = newOffset - this.scrollOffset + this.scrollOffset = newOffset + anchorResolved = true + } + } + } + } + } + + if (anchorResolved || followOnAppend) { + this.pendingScrollAnchor = [ + anchorResolved ? anchor![0] : null, + anchorResolved ? anchor![1] : 0, + followOnAppend, + anchorDelta, + ] + } + } + + private _lastMirrorWarnAt = 0 + + private notify = (sync: boolean) => { + if (process.env.NODE_ENV !== 'production') { + this.assertScrollMirrorInvariant() + } + this.options.onChange?.(this, sync) + } + + // Dev-only invariant: whenever we are idle with no in-flight write + // transaction, the scrollOffset mirror must agree with the DOM. A + // violation means some path mutated the mirror from intent instead of + // observation — the exact state corruption class behind "blank band until + // the next scroll". Throttled to at most one check per second (the DOM + // read can force layout, and notify fires on every stream flush). + private assertScrollMirrorInvariant = () => { + if ( + this.isScrolling || + this.scrollState !== null || + this._intendedScrollOffset !== null || + this._iosDeferredAdjustment !== 0 || + this.scrollOffset === null || + !this.scrollElement + ) { + return + } + const now = this.now() + if (now - this._lastMirrorWarnAt < 1000) return + this._lastMirrorWarnAt = now + const real = this.readDomScrollOffset() + if (real === null) return + if (Math.abs(real - this.scrollOffset) <= 2) return + console.warn( + `[virtual-core] scrollOffset mirror (${this.scrollOffset}) diverged from DOM (${real}) ` + + 'with no pending write transaction; the DOM must stay the single source of truth.', + ) + } + + private applyScrollAdjustment(delta: number, behavior?: ScrollBehavior) { + if (delta === 0) return + + if (process.env.NODE_ENV !== 'production' && this.options.debug) { + console.info('correction', delta) + } + + if ( + isIOSWebKit() && + (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) + ) { + this._iosDeferredAdjustment += delta + } else { + this._scrollToOffset(this.getScrollOffset(), { + adjustments: (this.scrollAdjustments += delta), + behavior, + }) + // Eagerly carry the intended target in `scrollOffset` so callers that + // read it before the next scroll event — notably the next `resizeItem` + // tick's `getVirtualDistanceFromEnd()` / `wasAtEnd` check — see the + // post-adjustment position even when the DOM `scrollTop` write was + // clamped because the consumer hasn't grown the sizer yet (`notify()` + // runs after this in `resizeItem`). Same idea as the eager + // `scrollOffset` adjustment for prepend in `setOptions` (#1176). The + // adjustment is now baked into `scrollOffset`, so zero + // `scrollAdjustments` to keep their sum invariant. + if (this.scrollOffset !== null) { + this.scrollOffset += this.scrollAdjustments + this.scrollAdjustments = 0 + } + this.scheduleScrollWriteVerify() + } + } + + // Reads the DOM's current scroll offset directly, bypassing the mirror. + private readDomScrollOffset = (): number | null => { + const el = this.scrollElement + if (!el) return null + if ('scrollHeight' in el) { + // Element + return this.options.horizontal + ? el.scrollLeft * ((this.options.isRtl && -1) || 1) + : el.scrollTop + } + // Window + return this.options.horizontal ? el.scrollX : el.scrollY + } + + private scheduleScrollWriteVerify = () => { + if (!this.targetWindow) return + if (this._writeVerifyRafId != null) return + this._writeVerifyRafId = this.targetWindow.requestAnimationFrame(() => { + this._writeVerifyRafId = null + this.verifyScrollWriteLanded() + }) + } + + private verifyScrollWriteLanded = () => { + // A scroll event already consumed the intent (echo arrived) — reconciled. + if (this._intendedScrollOffset === null) return + // Programmatic scrolls (scrollToIndex/scrollToOffset) own their own + // convergence loop with a safety valve; don't fight reconcileScroll. + if (this.scrollState) return + const real = this.readDomScrollOffset() + if (real === null) return + // Same tolerance the scroll-event reconciliation uses for subpixel + // rounding of our own writes. + if (Math.abs(real - this._intendedScrollOffset) < 1.5) return + // The write was swallowed (compositor gesture) or clamped (sizer not + // grown yet) and produced no scroll event that could re-sync us. Adopt + // the DOM's value and recompute the range so rendering matches the real + // viewport again — without waiting for the user's next scroll. + this._intendedScrollOffset = null + this.scrollOffset = real + this.maybeNotify() + } + + private maybeNotify = memo( + () => { + this.calculateRange() + + return [ + this.isScrolling, + this.range ? this.range.startIndex : null, + this.range ? this.range.endIndex : null, + ] + }, + (isScrolling) => { + this.notify(isScrolling) + }, + { + key: process.env.NODE_ENV !== 'production' && 'maybeNotify', + debug: () => this.options.debug, + initialDeps: [ + this.isScrolling, + this.range ? this.range.startIndex : null, + this.range ? this.range.endIndex : null, + ] as [boolean, number | null, number | null], + }, + ) + + private cleanup = () => { + this.unsubs.filter(Boolean).forEach((d) => d!()) + this.unsubs = [] + this.observer.disconnect() + if (this.rafId != null && this.targetWindow) { + this.targetWindow.cancelAnimationFrame(this.rafId) + this.rafId = null + } + if (this._writeVerifyRafId != null && this.targetWindow) { + this.targetWindow.cancelAnimationFrame(this._writeVerifyRafId) + this._writeVerifyRafId = null + } + this.scrollState = null + // The iOS gesture/deferral state is scoped to the current scroll + // element: the touch listeners that maintain it were just removed, and + // an in-flight touch keeps targeting the old element (implicit touch + // capture), so the new element never reports it. Carrying the state + // over would replay a stale deferred delta on the new element's first + // flush, and a cleanup that lands mid-touch or inside the post-touchend + // grace window would strand _iosTouching / _iosJustTouchEnded as true + // (the listener unsub clears the grace timer, and with it the only + // pending reset of the flag), deferring every adjustment on the new + // element until its next touch cycle. + this._iosDeferredAdjustment = 0 + this._iosTouching = false + this._iosJustTouchEnded = false + // Origin debt is scoped to the current scroll element's DOM scroll + // position; carrying it to a re-attached element would misplace every + // row by the stale debt. + this.originOffset = 0 + this.lastScrollDirection = null + this.scrollElement = null + this.targetWindow = null + } + + _didMount = () => { + return () => { + this.cleanup() + } + } + + _willUpdate = () => { + const scrollElement = this.options.enabled + ? this.options.getScrollElement() + : null + + if (this.scrollElement !== scrollElement) { + this.cleanup() + + if (!scrollElement) { + this.maybeNotify() + return + } + + this.scrollElement = scrollElement + + if (this.scrollElement && 'ownerDocument' in this.scrollElement) { + this.targetWindow = this.scrollElement.ownerDocument.defaultView + } else { + this.targetWindow = this.scrollElement?.window ?? null + } + + this.elementsCache.forEach((cached) => { + this.observer.observe(cached) + }) + + this.unsubs.push( + this.options.observeElementRect(this, (rect) => { + this.scrollRect = rect + this.maybeNotify() + }), + ) + + this.unsubs.push( + this.options.observeElementOffset(this, (offset, isScrolling) => { + // A scroll event that reports movement but lands on the offset we + // already hold — and isn't a self-write read-back — is a spurious + // no-op re-emit that Safari/Firefox fire after a re-render's layout + // (Chrome doesn't). Treating it as scrolling re-arms `isScrolling`, + // which forces a render that triggers another such event: an + // infinite re-render loop. Ignore it. (Self-writes are handled by + // the `_intendedScrollOffset` reconciliation just below.) + if ( + isScrolling && + this._intendedScrollOffset === null && + offset === this.scrollOffset + ) { + return + } + + // If this scroll event looks like the browser's read-back of a + // value we just wrote, prefer our intended (sub-pixel-accurate) + // value over the browser's rounded one. The 1.5 px tolerance is + // tight enough to avoid mistaking a real user scroll for a + // self-write — by the time the user has moved 1.5 px, the + // intended value will already have been consumed by a prior + // scroll event and cleared. + if ( + this._intendedScrollOffset !== null && + Math.abs(offset - this._intendedScrollOffset) < 1.5 + ) { + offset = this._intendedScrollOffset + } + this._intendedScrollOffset = null + + this.scrollAdjustments = 0 + // If the offset hasn't moved, this is the echo of our own + // adjustment write — `applyScrollAdjustment` already folded it + // into `scrollOffset`. There's no direction to infer, so leave + // it alone; a real gesture always moves the offset. + const prevOffset = this.getScrollOffset() + this.scrollDirection = isScrolling + ? prevOffset === offset + ? this.scrollDirection + : prevOffset < offset + ? 'forward' + : 'backward' + : null + if (this.scrollDirection !== null) { + this.lastScrollDirection = this.scrollDirection + } + this.scrollOffset = offset + this.isScrolling = isScrolling + + // Flush deferred iOS adjustments if we're now fully settled. + // "Fully settled" means: not actively scrolling, no finger on + // screen, and the post-touchend grace window has expired. + this._flushIosDeferredIfReady() + + if (this.scrollState) { + this.scheduleScrollReconcile() + } + this.maybeNotify() + // A scroll event is the freshest DOM truth we ever hold — the + // safest moment to settle origin debt (no-op in 'offset' mode). + this.maybeRebaseOrigin() + }), + ) + + // Touch event listeners (iOS-aware deferral). We attach unconditionally + // — the listeners are passive and cheap; on non-touch devices they + // simply never fire. The gating by isIOSWebKit() lives in resizeItem + // and _flushIosDeferredIfReady so we only burn the path on iOS. + if ('addEventListener' in this.scrollElement) { + const scrollEl = this.scrollElement as unknown as EventTarget + const onTouchStart = () => { + this._iosTouching = true + this._iosJustTouchEnded = false + if (this._iosTouchEndTimerId !== null && this.targetWindow != null) { + this.targetWindow.clearTimeout(this._iosTouchEndTimerId) + this._iosTouchEndTimerId = null + } + } + const onTouchEnd = () => { + this._iosTouching = false + if (!isIOSWebKit() || this.targetWindow == null) { + // Non-iOS: nothing more to track. Just clear the touching flag. + return + } + this._iosJustTouchEnded = true + // After ~150 ms with no scroll/touch events, momentum is done. + this._iosTouchEndTimerId = this.targetWindow.setTimeout(() => { + this._iosJustTouchEnded = false + this._iosTouchEndTimerId = null + // After the grace window, attempt to flush. The scroll event + // for momentum decay may have already fired before our timer. + this._flushIosDeferredIfReady() + }, 150) + } + scrollEl.addEventListener( + 'touchstart', + onTouchStart, + addEventListenerOptions, + ) + scrollEl.addEventListener( + 'touchend', + onTouchEnd, + addEventListenerOptions, + ) + this.unsubs.push(() => { + scrollEl.removeEventListener('touchstart', onTouchStart) + scrollEl.removeEventListener('touchend', onTouchEnd) + if (this._iosTouchEndTimerId !== null && this.targetWindow != null) { + this.targetWindow.clearTimeout(this._iosTouchEndTimerId) + this._iosTouchEndTimerId = null + } + }) + } + + this._scrollToOffset(this.getScrollOffset(), { + adjustments: undefined, + behavior: undefined, + }) + } + + const anchor = this.pendingScrollAnchor + this.pendingScrollAnchor = null + + if (anchor && this.scrollElement && this.options.enabled) { + const [key, _offset, followOnAppend, anchorDelta] = anchor + + if (key !== null && !followOnAppend) { + // scrollOffset was eagerly adjusted in setOptions so the + // virtualizer already computed the correct range during render. + // Now sync the browser's actual scroll position to match. + // Skip when followOnAppend is set — scrollToEnd will handle it. + // + // On iOS WebKit, writing scrollTop during touch/momentum cancels + // the in-flight scroll. Defer the DOM sync the same way + // applyScrollAdjustment does — accumulate the delta and let + // _flushIosDeferredIfReady handle it once the scroll settles. + if ( + isIOSWebKit() && + (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) + ) { + if (anchorDelta !== 0) { + this._iosDeferredAdjustment += anchorDelta + } + } else { + this._scrollToOffset(this.getScrollOffset(), { + adjustments: undefined, + behavior: undefined, + }) + this.scheduleScrollWriteVerify() + } + } + + if (followOnAppend) { + this.scrollToEnd({ behavior: followOnAppend }) + } + } + } + + // Apply any accumulated iOS-deferred scroll adjustment, but only when we're + // truly settled — not actively scrolling, not under an active touch, and + // past the post-touchend grace window. Called from the scroll callback + // and the touchend grace-timer. + private _flushIosDeferredIfReady = () => { + if (this._iosDeferredAdjustment === 0) return + if (this.isScrolling) return + if (this._iosTouching) return + if (this._iosJustTouchEnded) return + // Phase 2b: Safari elastic-overscroll (rubber-band) lets scrollTop go + // negative or beyond scrollHeight - clientHeight. Writing scrollTop + // while in that zone snaps the page back to the clamped value at the + // end of the bounce, often discarding the user's intent. Skip the + // flush; the next in-bounds scroll event will retry. + const cur = this.getScrollOffset() + const max = this.getMaxScrollOffset() + if (cur < 0 || cur > max) return + const delta = this._iosDeferredAdjustment + this._iosDeferredAdjustment = 0 + // Roll the deferred delta into the running accumulator so any resize + // landing between now and the resulting scroll event computes from the + // post-flush offset rather than the stale one. + this._scrollToOffset(cur, { + adjustments: (this.scrollAdjustments += delta), + behavior: undefined, + }) + } + + private rafId: number | null = null + private scheduleScrollReconcile() { + if (!this.targetWindow) { + this.scrollState = null + return + } + if (this.rafId != null) return + this.rafId = this.targetWindow.requestAnimationFrame(() => { + this.rafId = null + this.reconcileScroll() + }) + } + private reconcileScroll() { + if (!this.scrollState) return + + const el = this.scrollElement + if (!el) return + + // Safety valve: bail out if reconciliation has been running too long + const MAX_RECONCILE_MS = 5000 + if (this.now() - this.scrollState.startedAt > MAX_RECONCILE_MS) { + this.scrollState = null + return + } + + const offsetInfo = + this.scrollState.index != null + ? this.getOffsetForIndex(this.scrollState.index, this.scrollState.align) + : undefined + const targetOffset = offsetInfo + ? offsetInfo[0] + : this.scrollState.lastTargetOffset + + // Require one stable frame where target matches scroll offset. + // approxEqual() already tolerates minor fluctuations, so one frame is sufficient + // to confirm scroll has reached its target without premature cleanup. + const STABLE_FRAMES = 1 + + const targetChanged = targetOffset !== this.scrollState.lastTargetOffset + + if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) { + this.scrollState.stableFrames++ + if (this.scrollState.stableFrames >= STABLE_FRAMES) { + // Final-pass exact landing. The reconcile-stable check uses a 1.01px + // tolerance (approxEqual) so we don't fight subpixel browser rounding + // during the converging phase. Once we're definitively settled, + // commit the exact target so consumers calling scrollToIndex(N) + // end up at the EXACT computed position of item N — matching + // virtuoso's 0px landing accuracy rather than our prior 0.5-1px. + if (this.getScrollOffset() !== targetOffset) { + this._scrollToOffset(targetOffset, { + adjustments: undefined, + behavior: 'auto', + }) + } + this.scrollState = null + return + } + } else { + this.scrollState.stableFrames = 0 + + if (targetChanged) { + // When the target moves during smooth scroll (because items came into + // view and got measured, shifting positions), the original logic was + // to immediately snap to 'auto' — visibly jarring on long + // scroll-to-index calls. Now: keep smooth while we're still far + // (more than a viewport) from the new target. Only fall back to + // 'auto' for the final approach, so the user sees one continuous + // motion that smoothly adjusts its endpoint as measurements arrive. + const viewport = this.getSize() || 600 + const distance = Math.abs(targetOffset - this.getScrollOffset()) + const keepSmooth = + this.scrollState.behavior === 'smooth' && distance > viewport + + this.scrollState.lastTargetOffset = targetOffset + if (!keepSmooth) { + this.scrollState.behavior = 'auto' + } + + this._scrollToOffset(targetOffset, { + adjustments: undefined, + behavior: keepSmooth ? 'smooth' : 'auto', + }) + } + } + + // Always reschedule while scrollState is active to guarantee + // the safety valve timeout runs even if no scroll events fire + // (e.g. no-op scrollToFn, detached element) + this.scheduleScrollReconcile() + } + + private getSize = () => { + if (!this.options.enabled) { + this.scrollRect = null + return 0 + } + + this.scrollRect = this.scrollRect ?? this.options.initialRect + + return this.scrollRect[this.options.horizontal ? 'width' : 'height'] + } + + private getScrollOffset = () => { + if (!this.options.enabled) { + this.scrollOffset = null + return 0 + } + + this.scrollOffset = + this.scrollOffset ?? + (typeof this.options.initialOffset === 'function' + ? this.options.initialOffset() + : this.options.initialOffset) + + return this.scrollOffset + } + + private getMeasurementOptions = memo( + () => [ + this.options.count, + this.options.paddingStart, + this.options.scrollMargin, + this.options.getItemKey, + this.options.enabled, + this.options.lanes, + this.options.laneAssignmentMode, + this.options.gap, + ], + ( + count, + paddingStart, + scrollMargin, + getItemKey, + enabled, + lanes, + laneAssignmentMode, + gap, + ) => { + const lanesChanged = + this.prevLanes !== undefined && this.prevLanes !== lanes + + if (lanesChanged) { + // Set flag for getMeasurements to handle + this.lanesChangedFlag = true + } + + this.prevLanes = lanes + this.pendingMin = null + + return { + count, + paddingStart, + scrollMargin, + getItemKey, + enabled, + lanes, + laneAssignmentMode, + gap, + } + }, + { + key: false, + }, + ) + + private getMeasurements = memo( + () => [this.getMeasurementOptions(), this.itemSizeCacheVersion], + ( + { + count, + paddingStart, + scrollMargin, + getItemKey, + enabled, + lanes, + laneAssignmentMode, + gap, + }, + _itemSizeCacheVersion, + ) => { + const itemSizeCache = this.itemSizeCache + if (!enabled) { + this.measurementsCache = [] + this.itemSizeCache.clear() + this.laneAssignments.clear() + return [] + } + + // Clean up stale lane cache entries when count decreases + if (this.laneAssignments.size > count) { + for (const index of this.laneAssignments.keys()) { + if (index >= count) { + this.laneAssignments.delete(index) + } + } + } + + // ✅ Force complete recalculation when lanes change + if (this.lanesChangedFlag) { + this.lanesChangedFlag = false // Reset immediately + this.lanesSettling = true // Start settling period + this.measurementsCache = [] + this.itemSizeCache.clear() + this.laneAssignments.clear() // Clear lane cache for new lane count + // Force min = 0 on the rebuild + this.pendingMin = null + } + + // Don't restore from initialMeasurementsCache during lane changes + // as it contains stale lane assignments from the previous lane count + if (this.measurementsCache.length === 0 && !this.lanesSettling) { + this.measurementsCache = this.options.initialMeasurementsCache + this.measurementsCache.forEach((item) => { + this.itemSizeCache.set(item.key, item.size) + }) + } + + // During lanes settling, ignore pendingMin to prevent repositioning + const min = this.lanesSettling ? 0 : (this.pendingMin ?? 0) + this.pendingMin = null + + // ✅ End settling period when cache is fully built + if (this.lanesSettling && this.measurementsCache.length === count) { + this.lanesSettling = false + } + + // ─── Fast path: single-lane lazy materialization ──────────────────── + // For lanes === 1 (the default and most common case), skip the + // per-item VirtualItem object allocation. We write start/size pairs + // into a Float64Array and return a Proxy that builds VirtualItem + // objects on demand (only the indices a consumer actually reads). + // + // At n=100k this drops cold-mount cost from ~2.5ms (eager object + // allocation) to roughly the cost of a single typed-array fill. + if (lanes === 1) { + // Reuse flat backing if large enough; else grow (preserving data + // before `min` to mirror the slice-and-rebuild contract). + const need = count * 2 + let flat = this._flatMeasurements + if (!flat || flat.length < need) { + const next = new Float64Array(need) + if (flat && min > 0) next.set(flat.subarray(0, min * 2)) + flat = next + this._flatMeasurements = flat + } + + let runningStart: number + if (min === 0) { + runningStart = paddingStart + scrollMargin + this.originOffset + } else { + // Continue from where we left off + const prevIdx = min - 1 + runningStart = flat[prevIdx * 2]! + flat[prevIdx * 2 + 1]! + gap + } + + for (let i = min; i < count; i++) { + const key = getItemKey(i) + const measuredSize = itemSizeCache.get(key) + const size = + typeof measuredSize === 'number' + ? measuredSize + : this.options.estimateSize(i) + flat[i * 2] = runningStart + flat[i * 2 + 1] = size + runningStart += size + gap + } + + const view = createLazyMeasurementsView(count, flat, getItemKey) + this.measurementsCache = view + return view + } + + const measurements = this.measurementsCache.slice(0, min) + + // ✅ Performance: Track last item index per lane for O(1) lookup + const laneLastIndex: Array = new Array(lanes).fill( + undefined, + ) + // Running end position of each lane's last item, so the shortest lane + // can be found with an O(lanes) argmin instead of the old backward walk + // through `measurements` (getFurthestMeasurement). `filledLanes` tracks + // how many lanes have at least one item, mirroring the previous + // "all lanes seen → shortest lane, else i % lanes" branch. + const laneEnds = new Float64Array(lanes) + let filledLanes = 0 + + // Initialize from existing measurements (before min) + for (let m = 0; m < min; m++) { + const item = measurements[m] + if (item) { + if (laneLastIndex[item.lane] === undefined) filledLanes++ + laneLastIndex[item.lane] = m + laneEnds[item.lane] = item.end + } + } + + for (let i = min; i < count; i++) { + const key = getItemKey(i) + + // Check for cached lane assignment + const cachedLane = this.laneAssignments.get(i) + let lane: number + let start: number + + const shouldCacheLane = + laneAssignmentMode === 'estimate' || itemSizeCache.has(key) + + if (cachedLane !== undefined && this.options.lanes > 1) { + // Use cached lane - O(1) lookup for previous item in same lane + lane = cachedLane + const prevIndex = laneLastIndex[lane] + const prevInLane = + prevIndex !== undefined ? measurements[prevIndex] : undefined + start = prevInLane + ? prevInLane.end + gap + : paddingStart + scrollMargin + this.originOffset + } else if (filledLanes === lanes) { + // No cache, every lane seeded: place in the shortest lane. + // Read the running per-lane ends (O(lanes) argmin) instead of the + // old backward scan. Tie-break on the lane's last-item index to + // preserve the previous sort-by-(end, index) placement exactly. + let bestLane = 0 + let bestEnd = laneEnds[0]! + let bestIdx = laneLastIndex[0]! + for (let l = 1; l < lanes; l++) { + const e = laneEnds[l]! + if (e < bestEnd || (e === bestEnd && laneLastIndex[l]! < bestIdx)) { + bestLane = l + bestEnd = e + bestIdx = laneLastIndex[l]! + } + } + lane = bestLane + start = bestEnd + gap + + if (shouldCacheLane) { + this.laneAssignments.set(i, lane) + } + } else { + // No cache and not every lane seeded yet — seed lanes in order, + // matching the previous `i % lanes` fallback for the first row. + lane = i % this.options.lanes + start = paddingStart + scrollMargin + this.originOffset + + if (shouldCacheLane) { + this.laneAssignments.set(i, lane) + } + } + + const measuredSize = itemSizeCache.get(key) + const size = + typeof measuredSize === 'number' + ? measuredSize + : this.options.estimateSize(i) + + const end = start + size + + measurements[i] = { + index: i, + start, + size, + end, + key, + lane, + } + + // ✅ Performance: Update lane's last item index + running end + if (laneLastIndex[lane] === undefined) filledLanes++ + laneLastIndex[lane] = i + laneEnds[lane] = end + } + + this.measurementsCache = measurements + + return measurements + }, + { + key: process.env.NODE_ENV !== 'production' && 'getMeasurements', + debug: () => this.options.debug, + }, + ) + + calculateRange = memo( + () => [ + this.getMeasurements(), + this.getSize(), + this.getScrollOffset(), + this.options.lanes, + this.options.directionalOverscanPx, + this.lastScrollDirection, + ], + (measurements, outerSize, scrollOffset, lanes, overscanPx, direction) => { + if (measurements.length === 0 || outerSize === 0) { + this.range = null + return null + } + // Directional pixel overscan: widen the window toward where the user + // is heading (sticky to the last known direction so a settling scroll + // doesn't churn row mounts). Compositor-async viewports reveal this + // pre-rendered band before the main thread processes the next event. + const backwardExtra = + overscanPx > 0 && direction === 'backward' ? overscanPx : 0 + const forwardExtra = + overscanPx > 0 && direction === 'forward' ? overscanPx : 0 + this.range = calculateRangeImpl( + measurements, + outerSize + backwardExtra + forwardExtra, + scrollOffset - backwardExtra, + lanes, + // Pass the typed array so binary search + forward-walk can read + // start/end directly from Float64Array, skipping the Proxy traps. + lanes === 1 && this._flatMeasurements != null + ? this._flatMeasurements + : null, + ) + return this.range + }, + { + key: process.env.NODE_ENV !== 'production' && 'calculateRange', + debug: () => this.options.debug, + }, + ) + + getVirtualIndexes = memo( + () => { + let startIndex: number | null = null + let endIndex: number | null = null + const range = this.calculateRange() + if (range) { + startIndex = range.startIndex + endIndex = range.endIndex + } + this.maybeNotify.updateDeps([this.isScrolling, startIndex, endIndex]) + return [ + this.options.rangeExtractor, + this.options.overscan, + this.options.count, + startIndex, + endIndex, + ] + }, + (rangeExtractor, overscan, count, startIndex, endIndex) => { + return startIndex === null || endIndex === null + ? [] + : rangeExtractor({ + startIndex, + endIndex, + overscan, + count, + }) + }, + { + key: process.env.NODE_ENV !== 'production' && 'getVirtualIndexes', + debug: () => this.options.debug, + }, + ) + + indexFromElement = (node: TItemElement) => { + const attributeName = this.options.indexAttribute + const indexStr = node.getAttribute(attributeName) + + if (!indexStr) { + console.warn( + `Missing attribute name '${attributeName}={index}' on measured element.`, + ) + return -1 + } + + return parseInt(indexStr, 10) + } + + /** + * Determines if an item at the given index should be measured during smooth scroll. + * During smooth scroll, only items within a buffer range around the target are measured + * to prevent items far from the target from pushing it away. + */ + private shouldMeasureDuringScroll = (index: number): boolean => { + // No scroll state or not smooth scroll - always allow measurements + if (!this.scrollState || this.scrollState.behavior !== 'smooth') { + return true + } + + const scrollIndex = + this.scrollState.index ?? + this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)?.index + + if (scrollIndex !== undefined && this.range) { + // Allow measurements within a buffer range around the scroll target + const bufferSize = Math.max( + this.options.overscan, + Math.ceil((this.range.endIndex - this.range.startIndex) / 2), + ) + const minIndex = Math.max(0, scrollIndex - bufferSize) + const maxIndex = Math.min( + this.options.count - 1, + scrollIndex + bufferSize, + ) + return index >= minIndex && index <= maxIndex + } + + return true + } + + measureElement = (node: TItemElement | null) => { + if (!node) { + this.elementsCache.forEach((cached, key) => { + if (!cached.isConnected) { + this.observer.unobserve(cached) + this.elementsCache.delete(key) + } + }) + return + } + + const index = this.indexFromElement(node) + const key = this.options.getItemKey(index) + const prevNode = this.elementsCache.get(key) + + if (prevNode !== node) { + if (prevNode) { + this.observer.unobserve(prevNode) + } + this.observer.observe(node) + this.elementsCache.set(key, node) + } + + // Sync-measure when idle (initial render) or during programmatic scrolling + // (scrollToIndex/scrollToOffset) where reconcileScroll needs sizes in the same frame. + // During normal user scrolling, skip sync measurement — the RO callback handles it async. + if ( + (!this.isScrolling || this.scrollState) && + this.shouldMeasureDuringScroll(index) + ) { + this.resizeItem(index, this.options.measureElement(node, undefined, this)) + } + } + + resizeItem = (index: number, size: number) => { + if (index < 0 || index >= this.options.count) return + + // Fast field reads. For lanes===1 we read raw start/size from the flat + // typed array, avoiding a Proxy.get + VirtualItem allocation per call. + // For lanes>1 we fall back to the cached VirtualItem array. + let cachedSize: number + let itemStart: number + let key: Key + const flat = this._flatMeasurements + if (this.options.lanes === 1 && flat !== null) { + key = this.options.getItemKey(index) + itemStart = flat[index * 2]! + cachedSize = flat[index * 2 + 1]! + } else { + const item = this.measurementsCache[index] + if (!item) return + key = item.key + itemStart = item.start + cachedSize = item.size + } + + const itemSize = this.itemSizeCache.get(key) ?? cachedSize + const delta = size - itemSize + + if (delta !== 0) { + const wasAtEnd = + this.options.anchorTo === 'end' && + this.scrollState?.behavior !== 'smooth' && + this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold + const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0 + const shouldAdjustScroll = + this.scrollState?.behavior !== 'smooth' && + (this.shouldAdjustScrollPositionOnItemSizeChange !== undefined + ? this.shouldAdjustScrollPositionOnItemSizeChange( + // The callback expects a VirtualItem; build one lazily only + // when the consumer actually supplied a custom predicate. + this.measurementsCache[index] ?? { + index, + key, + start: itemStart, + size: cachedSize, + end: itemStart + cachedSize, + lane: 0, + }, + delta, + this, + ) + : // Default: adjust when the resize is an above-viewport item. + // First measurement (!has(key)): always adjust — the item + // has never been sized, so the estimate→actual delta must + // be compensated regardless of scroll direction. + // Re-measurement (has(key)): skip during backward scroll + // to avoid the "items jump while scrolling up" cascade. + itemStart < this.getScrollOffset() + this.scrollAdjustments && + (!this.itemSizeCache.has(key) || + this.scrollDirection !== 'backward')) + + if (this.pendingMin === null || index < this.pendingMin) { + this.pendingMin = index + } + this.itemSizeCache.set(key, size) + this.itemSizeCacheVersion++ + + if (wasAtEnd) { + // Bottom pinning while anchored to the end. In this repo's hosts this + // only engages while the scroll-follow engine is detached (anchorTo + // flips to 'start' while following), so it never double-writes with + // the app-level pin; the write itself is covered by write-landing + // verification. + this.applyScrollAdjustment(this.getTotalSize() - prevTotalSize) + } else if (shouldAdjustScroll) { + if (this.options.scrollAnchoring === 'origin') { + // Same visual contract as the scrollTop write (visible rows stay + // put, growth is absorbed above the viewport top) but expressed as + // a layout shift: zero programmatic scrolls to race the gesture. + this.absorbIntoOrigin(delta) + } else { + this.applyScrollAdjustment(delta) + } + } + + this.notify(false) + } + } + + // 'origin' anchoring: absorb an above-viewport size delta into the layout + // origin. Rows at and below the viewport keep their exact positions + // (origin −delta cancels the +delta their starts would gain), the resized + // row grows upward, and rows above shift up — all offscreen. scrollTop and + // the scrollOffset mirror stay untouched. + private absorbIntoOrigin = (delta: number) => { + this.originOffset -= delta + // Origin is baked into every position: force a full rebuild. The caller + // (resizeItem) already bumped the version once, but a consumer may have + // pulled measurements between that bump and this absorb, which would + // consume pendingMin for the current version — bump again so the + // origin-shifted rebuild can never be skipped. + this.pendingMin = 0 + this.itemSizeCacheVersion++ + } + + // Settle the accumulated origin debt with one verified scrollTop write. + // Deferred while the gesture owns the viewport; forced when the debt + // exceeds its budget or the viewport approaches the mis-positioned zone + // near the top (|originOffset| px of layout sit above y=0 / below their + // true position until a rebase). + private maybeRebaseOrigin = () => { + if (this.options.scrollAnchoring !== 'origin') return + if (this.originOffset === 0) return + // Programmatic scrolls own their own convergence loop. + if (this.scrollState) return + const size = this.getSize() + const nearTop = + this.getScrollOffset() < size * 2 + Math.abs(this.originOffset) + const overCap = Math.abs(this.originOffset) > Math.max(size * 2, 2000) + if (this.isScrolling && !nearTop && !overCap) { + // Mid-gesture rebases are exactly the racy writes this mode removes; + // wait for idle unless forced. + return + } + if (!this.isScrolling && !nearTop && !overCap && this.getDistanceFromEnd() < size) { + // Idle at the bottom: a positive-debt write here would clamp against + // the not-yet-grown sizer. The debt is harmless anywhere but the top, + // so keep waiting — scrolling away from the bottom settles it. + return + } + const delta = -this.originOffset + this.originOffset = 0 + this.pendingMin = 0 + this.itemSizeCacheVersion++ + // Mirror + DOM write + write-landing verification; then re-render with + // the rebased layout and offset in one consistent pass. + this.applyScrollAdjustment(delta) + this.notify(false) + } + + getVirtualItems = memo( + () => [this.getVirtualIndexes(), this.getMeasurements()], + (indexes, measurements) => { + const virtualItems: Array = [] + + for (let k = 0, len = indexes.length; k < len; k++) { + const i = indexes[k]! + const measurement = measurements[i]! + + virtualItems.push(measurement) + } + + return virtualItems + }, + { + key: process.env.NODE_ENV !== 'production' && 'getVirtualItems', + debug: () => this.options.debug, + }, + ) + + getVirtualItemForOffset = (offset: number) => { + const measurements = this.getMeasurements() + if (measurements.length === 0) { + return undefined + } + // Same fast-path as calculateRange: read start values directly from the + // typed array during binary search to skip the Proxy.get materialization + // per probe. + const flat = this._flatMeasurements + const useFlat = this.options.lanes === 1 && flat != null + const idx = findNearestBinarySearch( + 0, + measurements.length - 1, + useFlat + ? (i: number) => flat[i * 2]! + : (i: number) => notUndefined(measurements[i]).start, + offset, + ) + return notUndefined(measurements[idx]) + } + + private getMaxScrollOffset = () => { + if (!this.scrollElement) return 0 + + if ('scrollHeight' in this.scrollElement) { + // Element + return this.options.horizontal + ? this.scrollElement.scrollWidth - this.scrollElement.clientWidth + : this.scrollElement.scrollHeight - this.scrollElement.clientHeight + } else { + // Window + const doc = this.scrollElement.document.documentElement + return this.options.horizontal + ? doc.scrollWidth - this.scrollElement.innerWidth + : doc.scrollHeight - this.scrollElement.innerHeight + } + } + + private getVirtualDistanceFromEnd = () => { + return Math.max( + this.getTotalSize() - this.getSize() - this.getScrollOffset(), + 0, + ) + } + + getDistanceFromEnd = () => { + return Math.max(this.getMaxScrollOffset() - this.getScrollOffset(), 0) + } + + isAtEnd = (threshold = this.options.scrollEndThreshold) => { + return this.getDistanceFromEnd() <= threshold + } + + getOffsetForAlignment = ( + toOffset: number, + align: ScrollAlignment, + itemSize = 0, + ) => { + if (!this.scrollElement) return 0 + + const size = this.getSize() + const scrollOffset = this.getScrollOffset() + + if (align === 'auto') { + align = toOffset >= scrollOffset + size ? 'end' : 'start' + } + + if (align === 'center') { + // When aligning to a particular item (e.g. with scrollToIndex), + // adjust offset by the size of the item to center on the item + toOffset += (itemSize - size) / 2 + } else if (align === 'end') { + toOffset -= size + } + + const maxOffset = this.getMaxScrollOffset() + + return Math.max(Math.min(maxOffset, toOffset), 0) + } + + getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => { + index = Math.max(0, Math.min(index, this.options.count - 1)) + + const size = this.getSize() + const scrollOffset = this.getScrollOffset() + + const item = this.measurementsCache[index] + if (!item) return + + if (align === 'auto') { + if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) { + align = 'end' + } else if (item.start <= scrollOffset + this.options.scrollPaddingStart) { + align = 'start' + } else { + return [scrollOffset, align] as const + } + } + + // For the last item with 'end' alignment, use browser's actual max scroll + // to account for borders/padding that aren't in our measurements + if (align === 'end' && index === this.options.count - 1) { + return [this.getMaxScrollOffset(), align] as const + } + + const toOffset = + align === 'end' + ? item.end + this.options.scrollPaddingEnd + : item.start - this.options.scrollPaddingStart + + return [ + this.getOffsetForAlignment(toOffset, align, item.size), + align, + ] as const + } + + scrollToOffset = ( + toOffset: number, + { align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {}, + ) => { + const offset = this.getOffsetForAlignment(toOffset, align) + + const now = this.now() + this.scrollState = { + index: null, + align, + behavior, + startedAt: now, + lastTargetOffset: offset, + stableFrames: 0, + } + + this._scrollToOffset(offset, { adjustments: undefined, behavior }) + + this.scheduleScrollReconcile() + } + + scrollToIndex = ( + index: number, + { + align: initialAlign = 'auto', + behavior = 'auto', + }: ScrollToIndexOptions = {}, + ) => { + index = Math.max(0, Math.min(index, this.options.count - 1)) + + const offsetInfo = this.getOffsetForIndex(index, initialAlign) + if (!offsetInfo) { + return + } + const [offset, align] = offsetInfo + + const now = this.now() + this.scrollState = { + index, + align, + behavior, + startedAt: now, + lastTargetOffset: offset, + stableFrames: 0, + } + + this._scrollToOffset(offset, { adjustments: undefined, behavior }) + + this.scheduleScrollReconcile() + } + + scrollBy = ( + delta: number, + { behavior = 'auto' }: ScrollToOffsetOptions = {}, + ) => { + const offset = this.getScrollOffset() + delta + const now = this.now() + + this.scrollState = { + index: null, + align: 'start', + behavior, + startedAt: now, + lastTargetOffset: offset, + stableFrames: 0, + } + + this._scrollToOffset(offset, { adjustments: undefined, behavior }) + + this.scheduleScrollReconcile() + } + + scrollToEnd = ({ behavior = 'auto' }: ScrollToEndOptions = {}) => { + if (this.options.count > 0) { + this.scrollToIndex(this.options.count - 1, { + align: 'end', + behavior, + }) + return + } + + this.scrollToOffset(Math.max(this.getTotalSize() - this.getSize(), 0), { + behavior, + }) + } + + getTotalSize = () => { + const measurements = this.getMeasurements() + + let end: number + // If there are no measurements, set the end to paddingStart + // If there is only one lane, use the last measurement's end + // Otherwise find the maximum end value among all measurements + if (measurements.length === 0) { + end = this.options.paddingStart + } else if (this.options.lanes === 1) { + // Fast path: read last item's end directly from the flat typed array + // when available; avoids a Proxy.get + VirtualItem materialization + // just to call getTotalSize (which React renders trigger every commit). + const lastIdx = measurements.length - 1 + const flat = this._flatMeasurements + if (flat != null) { + end = flat[lastIdx * 2]! + flat[lastIdx * 2 + 1]! + } else { + end = measurements[lastIdx]?.end ?? 0 + } + } else { + const endByLane = Array(this.options.lanes).fill(null) + let endIndex = measurements.length - 1 + while (endIndex >= 0 && endByLane.some((val) => val === null)) { + const item = measurements[endIndex]! + if (endByLane[item.lane] === null) { + endByLane[item.lane] = item.end + } + + endIndex-- + } + + end = Math.max(...endByLane.filter((val): val is number => val !== null)) + } + + return Math.max( + end - this.options.scrollMargin + this.options.paddingEnd, + 0, + ) + } + + /** + * Returns a snapshot of currently-measured items suitable for round- + * tripping through state storage (sessionStorage, history, etc.) and + * passing back as `initialMeasurementsCache` on remount. Pair with the + * current `scrollOffset` to restore exact scroll position after navigation. + * + * Only items the consumer has actually rendered (and thus measured) appear + * in the snapshot; unmeasured items will fall back to `estimateSize` on + * restore. Returns an empty array if no items have been measured. + */ + takeSnapshot = (): Array => { + const snapshot: Array = [] + if (this.itemSizeCache.size === 0) return snapshot + // Iterate measurementsCache only for indices whose key is in itemSizeCache + // (i.e., have been measured). We build VirtualItem objects with the + // current start/size/end so they can be persisted as plain data. + const m = this.getMeasurements() + for (const item of m) { + if (item && this.itemSizeCache.has(item.key)) { + // Force materialization (lazy path) and copy plain fields. + snapshot.push({ + index: item.index, + key: item.key, + start: item.start, + size: item.size, + end: item.end, + lane: item.lane, + }) + } + } + return snapshot + } + + private _scrollToOffset = ( + offset: number, + { + adjustments, + behavior, + }: { + adjustments: number | undefined + behavior: ScrollBehavior | undefined + }, + ) => { + // Record the intended logical scroll target so the next scroll event + // can reconcile against subpixel rounding by the browser. + this._intendedScrollOffset = offset + (adjustments ?? 0) + this.options.scrollToFn(offset, { behavior, adjustments }, this) + } + + measure = () => { + // Reset pendingMin so the next getMeasurements rebuilds from index 0. + // Without this, a prior resizeItem() that left pendingMin > 0 would + // cause the rebuild to preserve stale items before that index. + this.pendingMin = null + this.itemSizeCache.clear() + this.laneAssignments.clear() // Clear lane cache for full re-layout + this.itemSizeCacheVersion++ + this.notify(false) + } +} + +const findNearestBinarySearch = ( + low: number, + high: number, + getCurrentValue: (i: number) => number, + value: number, +) => { + while (low <= high) { + const middle = ((low + high) / 2) | 0 + const currentValue = getCurrentValue(middle) + + if (currentValue < value) { + low = middle + 1 + } else if (currentValue > value) { + high = middle - 1 + } else { + return middle + } + } + + if (low > 0) { + return low - 1 + } else { + return 0 + } +} + +// Monomorphic Float64Array variant — reads start values directly at stride +// 2 instead of through a getter closure. JITs the inner load to a typed- +// array bounds-check + load with no indirect call. +function findNearestBinarySearchFlat( + flat: Float64Array, + high: number, + value: number, +) { + let low = 0 + while (low <= high) { + const middle = ((low + high) / 2) | 0 + const currentValue = flat[middle * 2]! + + if (currentValue < value) { + low = middle + 1 + } else if (currentValue > value) { + high = middle - 1 + } else { + return middle + } + } + return low > 0 ? low - 1 : 0 +} + +function calculateRangeImpl( + measurements: Array, + outerSize: number, + scrollOffset: number, + lanes: number, + flat: Float64Array | null, +) { + const lastIndex = measurements.length - 1 + + // handle case when item count is less than or equal to lanes + if (measurements.length <= lanes) { + return { startIndex: 0, endIndex: lastIndex } + } + + if (lanes === 1 && flat !== null) { + // Hot single-lane path: typed-array reads, no closures, no Proxy traps. + const startIndex = findNearestBinarySearchFlat( + flat, + lastIndex, + scrollOffset, + ) + let endIndex = startIndex + const limit = scrollOffset + outerSize + while ( + endIndex < lastIndex && + flat[endIndex * 2]! + flat[endIndex * 2 + 1]! < limit + ) { + endIndex++ + } + return { startIndex, endIndex } + } + + // Fallback (lanes > 1 or no flat array): closure-based reads. + const getStart = (index: number) => measurements[index]!.start + let startIndex = findNearestBinarySearch(0, lastIndex, getStart, scrollOffset) + let endIndex = startIndex + + if (lanes === 1) { + while ( + endIndex < lastIndex && + measurements[endIndex]!.end < scrollOffset + outerSize + ) { + endIndex++ + } + } else if (lanes > 1) { + // Expand forward until we include the visible items from all lanes + // which are closer to the end of the virtualizer window + const endPerLane = Array(lanes).fill(0) + while ( + endIndex < lastIndex && + endPerLane.some((pos) => pos < scrollOffset + outerSize) + ) { + const item = measurements[endIndex]! + endPerLane[item.lane] = item.end + endIndex++ + } + + // Expand backward until we include all lanes' visible items + // closer to the top + const startPerLane = Array(lanes).fill(scrollOffset + outerSize) + while (startIndex >= 0 && startPerLane.some((pos) => pos >= scrollOffset)) { + const item = measurements[startIndex]! + startPerLane[item.lane] = item.start + startIndex-- + } + + // Align startIndex to the beginning of its lane + startIndex = Math.max(0, startIndex - (startIndex % lanes)) + // Align endIndex to the end of its lane + endIndex = Math.min(lastIndex, endIndex + (lanes - 1 - (endIndex % lanes))) + } + + return { startIndex, endIndex } +} diff --git a/crates/virtual-core/src/lazy-measurements.ts b/crates/virtual-core/src/lazy-measurements.ts new file mode 100644 index 000000000..4b8cd425d --- /dev/null +++ b/crates/virtual-core/src/lazy-measurements.ts @@ -0,0 +1,44 @@ +// Lazy materialization for the lanes===1 fast path. Backed by a +// Float64Array (stride 2: start, size, …); VirtualItems are constructed on +// first indexed read and cached. Saves the per-item object allocation at +// large list counts where most items are never visible. + +import type { VirtualItem } from './index' + +type Key = number | string | bigint + +export function createLazyMeasurementsView( + count: number, + flat: Float64Array, + getItemKey: (i: number) => Key, +): Array { + const cache: Array = new Array(count) + return new Proxy(cache as any, { + get(target, prop, receiver) { + if (typeof prop === 'string') { + // Cheap digit-prefix sniff before number coerce. + const c = prop.charCodeAt(0) + if (c >= 48 && c <= 57) { + const i = +prop + if (Number.isInteger(i) && i >= 0 && i < count) { + let v = target[i] + if (!v) { + const s = flat[i * 2]! + v = target[i] = { + index: i, + key: getItemKey(i), + start: s, + size: flat[i * 2 + 1]!, + end: s + flat[i * 2 + 1]!, + lane: 0, + } + } + return v + } + } + if (prop === 'length') return count + } + return Reflect.get(target, prop, receiver) + }, + }) as Array +} diff --git a/crates/virtual-core/src/process-env.d.ts b/crates/virtual-core/src/process-env.d.ts new file mode 100644 index 000000000..623865775 --- /dev/null +++ b/crates/virtual-core/src/process-env.d.ts @@ -0,0 +1,6 @@ +// The source keeps upstream's literal `process.env.NODE_ENV` reads so +// bundlers can statically replace them and dead-code-eliminate the debug +// branches. This ambient declaration only serves this package's own +// standalone typecheck; consumers never load it (their type surface is +// types/index.d.ts via the exports map). +declare const process: { env: { NODE_ENV?: string } }; diff --git a/crates/virtual-core/src/utils.ts b/crates/virtual-core/src/utils.ts new file mode 100644 index 000000000..5e16080cd --- /dev/null +++ b/crates/virtual-core/src/utils.ts @@ -0,0 +1,109 @@ +export type NoInfer = [A][A extends any ? 0 : never] + +export type PartialKeys = Omit & Partial> + +export function memo, TResult>( + getDeps: () => [...TDeps], + fn: (...args: NoInfer<[...TDeps]>) => TResult, + opts: { + key: false | string + debug?: () => boolean + onChange?: (result: TResult) => void + initialDeps?: TDeps + skipInitialOnChange?: boolean + }, +) { + let deps = opts.initialDeps ?? [] + let result: TResult | undefined + let isInitial = true + + function memoizedFunction(): TResult { + // Debug-only timing. In production builds, `process.env.NODE_ENV !== + // 'production'` is constant-folded to `false` by downstream minifiers + // (Terser/esbuild/swc with `define`), which DCEs the entire block. + const debugEnabled = + process.env.NODE_ENV !== 'production' && !!opts.key && !!opts.debug?.() + let depTime = 0 + if (debugEnabled) depTime = Date.now() + + const newDeps = getDeps() + + const depsChanged = + newDeps.length !== deps.length || + newDeps.some((dep: any, index: number) => deps[index] !== dep) + + if (!depsChanged) { + return result! + } + + deps = newDeps + + let resultTime = 0 + if (debugEnabled) resultTime = Date.now() + + result = fn(...newDeps) + + if (debugEnabled) { + const depEndTime = Math.round((Date.now() - depTime) * 100) / 100 + const resultEndTime = Math.round((Date.now() - resultTime) * 100) / 100 + const resultFpsPercentage = resultEndTime / 16 + + const pad = (str: number | string, num: number) => { + str = String(str) + while (str.length < num) { + str = ' ' + str + } + return str + } + + console.info( + `%c⏱ ${pad(resultEndTime, 5)} /${pad(depEndTime, 5)} ms`, + ` + font-size: .6rem; + font-weight: bold; + color: hsl(${Math.max( + 0, + Math.min(120 - 120 * resultFpsPercentage, 120), + )}deg 100% 31%);`, + opts?.key, + ) + } + + if (opts?.onChange && !(isInitial && opts.skipInitialOnChange)) { + opts.onChange(result) + } + + isInitial = false + + return result + } + + // Attach updateDeps to the function itself + memoizedFunction.updateDeps = (newDeps: [...TDeps]) => { + deps = newDeps + } + + return memoizedFunction +} + +export function notUndefined(value: T | undefined, msg?: string): T { + if (value === undefined) { + throw new Error(`Unexpected undefined${msg ? `: ${msg}` : ''}`) + } else { + return value + } +} + +export const approxEqual = (a: number, b: number) => Math.abs(a - b) < 1.01 + +export const debounce = ( + targetWindow: Window & typeof globalThis, + fn: Function, + ms: number, +) => { + let timeoutId: number + return function (this: any, ...args: Array) { + targetWindow.clearTimeout(timeoutId) + timeoutId = targetWindow.setTimeout(() => fn.apply(this, args), ms) + } +} diff --git a/crates/virtual-core/test/directional-overscan.test.mjs b/crates/virtual-core/test/directional-overscan.test.mjs new file mode 100644 index 000000000..96ec95df7 --- /dev/null +++ b/crates/virtual-core/test/directional-overscan.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createHarness } from "./helpers/harness.mjs"; + +// Directional pixel overscan: the visible window is widened toward the last +// known scroll direction so compositor-async scrolling (which paints ahead +// of the main thread) reveals pre-rendered rows instead of blank space. + +test("backward scrolling pre-renders rows above the viewport", () => { + const plain = createHarness(); + const overscanned = createHarness({ directionalOverscanPx: 480 }); + + plain.emitScroll(9880, true); + overscanned.emitScroll(9880, true); + + const plainFirst = plain.virtualizer.getVirtualItems()[0].index; + const overscannedFirst = overscanned.virtualizer.getVirtualItems()[0].index; + // 480px at 100px estimates is at least 4 extra rows above. + assert.ok( + overscannedFirst <= plainFirst - 4, + `expected extension above (plain ${plainFirst}, overscanned ${overscannedFirst})`, + ); +}); + +test("forward scrolling pre-renders rows below the viewport", () => { + const h = createHarness({ directionalOverscanPx: 480, initialOffset: 5000 }); + h.emitScroll(5120, true); + const last = h.virtualizer.getVirtualItems().at(-1); + // Window end 5720 plus 480 of forward overscan reaches past row 61. + assert.ok(last.index >= 61, `expected extension below, got ${last.index}`); +}); + +test("the extension sticks to the last direction when scrolling settles", () => { + const h = createHarness({ directionalOverscanPx: 480 }); + h.emitScroll(9880, true); + const duringScroll = h.virtualizer.getVirtualItems()[0].index; + h.emitScroll(9880, false); + const afterSettle = h.virtualizer.getVirtualItems()[0].index; + assert.equal(afterSettle, duringScroll, "settling must not churn the mounted range"); +}); diff --git a/crates/virtual-core/test/helpers/harness.mjs b/crates/virtual-core/test/helpers/harness.mjs new file mode 100644 index 000000000..5a87d7069 --- /dev/null +++ b/crates/virtual-core/test/helpers/harness.mjs @@ -0,0 +1,141 @@ +import { loadVirtualCore } from "./load-core.mjs"; + +// Deterministic virtualizer harness: a fake scroll element whose DOM offset +// only moves when a scrollToFn write is allowed to land, an explicit rAF +// queue, and explicit scroll-event emission. Nothing is asynchronous — tests +// drive every step. +export function createHarness(options = {}) { + const { + count = 200, + estimate = 100, + viewport = 600, + initialOffset = 10000, + anchorTo = "end", + scrollAnchoring, + directionalOverscanPx, + overscan = 0, + scrollEndThreshold = 8, + } = options; + + const core = loadVirtualCore(); + const state = { + realScrollTop: initialOffset, + swallowWrites: false, + writes: [], + rafQueue: new Map(), + rafSeq: 0, + scrollCb: null, + }; + + const fakeWindow = { + performance: { now: () => Date.now() }, + requestAnimationFrame: (fn) => { + const id = ++state.rafSeq; + state.rafQueue.set(id, fn); + return id; + }, + cancelAnimationFrame: (id) => { + state.rafQueue.delete(id); + }, + setTimeout: (...args) => setTimeout(...args), + clearTimeout: (id) => clearTimeout(id), + }; + + const fakeElement = { + ownerDocument: { defaultView: fakeWindow }, + addEventListener: () => {}, + removeEventListener: () => {}, + scrollTo: () => {}, + get scrollTop() { + return state.realScrollTop; + }, + get scrollHeight() { + return virtualizer.getTotalSize(); + }, + clientHeight: viewport, + }; + + const virtualizer = new core.Virtualizer({ + count, + getScrollElement: () => fakeElement, + estimateSize: () => estimate, + getItemKey: options.getItemKey ?? ((index) => `row-${index}`), + scrollToFn: (offset, { adjustments }) => { + const target = offset + (adjustments ?? 0); + if (state.swallowWrites) { + state.writes.push({ target, swallowed: true }); + return; + } + const max = Math.max(0, virtualizer.getTotalSize() - viewport); + state.realScrollTop = Math.max(0, Math.min(max, target)); + state.writes.push({ target, swallowed: false, landed: state.realScrollTop }); + }, + observeElementRect: (_instance, cb) => { + cb({ width: 800, height: viewport }); + }, + observeElementOffset: (_instance, cb) => { + state.scrollCb = cb; + cb(state.realScrollTop, false); + }, + overscan, + anchorTo, + ...(scrollAnchoring !== undefined ? { scrollAnchoring } : {}), + ...(directionalOverscanPx !== undefined ? { directionalOverscanPx } : {}), + scrollEndThreshold, + initialOffset, + }); + virtualizer._didMount(); + virtualizer._willUpdate(); + // The mount-time offset sync write is bookkeeping noise for assertions. + state.writes.length = 0; + + return { + core, + virtualizer, + element: fakeElement, + get realScrollTop() { + return state.realScrollTop; + }, + get writes() { + return state.writes; + }, + setSwallowWrites(value) { + state.swallowWrites = value; + }, + // The user (or compositor) moved the viewport and the browser reported it. + emitScroll(offset, isScrolling = true) { + state.realScrollTop = offset; + state.scrollCb?.(offset, isScrolling); + }, + // The browser echoes our own write back as a scroll event. + emitEcho(isScrolling = true) { + state.scrollCb?.(state.realScrollTop, isScrolling); + }, + runRafs() { + const pending = [...state.rafQueue.values()]; + state.rafQueue.clear(); + for (const fn of pending) fn(); + }, + // Coverage math in real-viewport coordinates: how many pixels at the top + // of the visible viewport have no rendered row under them. Virtual items + // are contiguous and position-sorted, so the first item's start bounds + // the rendered region from above. + blankBandAtViewportTop() { + const items = virtualizer.getVirtualItems(); + const first = items[0]; + if (!first) return viewport; + return Math.max(0, first.start - state.realScrollTop); + }, + originOffset() { + return virtualizer.originOffset ?? 0; + }, + itemByKey(key) { + const measurements = virtualizer.getMeasurements(); + for (let i = 0; i < measurements.length; i += 1) { + const item = measurements[i]; + if (item && item.key === key) return { ...item }; + } + return null; + }, + }; +} diff --git a/crates/virtual-core/test/helpers/load-core.mjs b/crates/virtual-core/test/helpers/load-core.mjs new file mode 100644 index 000000000..7b7bee9ad --- /dev/null +++ b/crates/virtual-core/test/helpers/load-core.mjs @@ -0,0 +1,44 @@ +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; +import { transpileTypeScriptModule } from "../../../../scripts/typescript-source-tools.mjs"; + +// Minimal TS loader for the vendored virtual-core source: the package has +// zero runtime dependencies, so only relative specifiers need resolving. +const srcDir = fileURLToPath(new URL("../../src", import.meta.url)); +const EXTENSIONS = [".ts", ".js"]; + +function resolveRelative(specifier, parentDir) { + const candidate = path.resolve(parentDir, specifier); + if (path.extname(candidate) && fs.existsSync(candidate)) return candidate; + for (const ext of EXTENSIONS) { + if (fs.existsSync(`${candidate}${ext}`)) return `${candidate}${ext}`; + } + throw new Error(`Cannot resolve module path: ${candidate}`); +} + +const cache = new Map(); + +function loadFile(filePath) { + if (cache.has(filePath)) return cache.get(filePath).exports; + const source = fs.readFileSync(filePath, "utf8"); + const outputText = transpileTypeScriptModule(source, filePath); + const module = { exports: {} }; + cache.set(filePath, module); + const dirname = path.dirname(filePath); + const localRequire = (specifier) => { + if (!specifier.startsWith(".")) { + throw new Error(`virtual-core must stay dependency-free; got import of ${specifier}`); + } + return loadFile(resolveRelative(specifier, dirname)); + }; + const wrapped = `(function (exports, require, module, __filename, __dirname) {\n${outputText}\n})`; + const compiled = new vm.Script(wrapped, { filename: filePath }).runInThisContext(); + compiled(module.exports, localRequire, module, filePath, dirname); + return module.exports; +} + +export function loadVirtualCore() { + return loadFile(path.join(srcDir, "index.ts")); +} diff --git a/crates/virtual-core/test/origin-anchoring.test.mjs b/crates/virtual-core/test/origin-anchoring.test.mjs new file mode 100644 index 000000000..c4ac773eb --- /dev/null +++ b/crates/virtual-core/test/origin-anchoring.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createHarness } from "./helpers/harness.mjs"; + +// 'origin' scroll anchoring: above-viewport size corrections are absorbed +// into a layout origin instead of written to scrollTop, so no programmatic +// scroll can race the user's gesture. The accumulated debt is settled by a +// single verified write at a safe moment (idle / debt cap / near the top). + +const ACTUAL = 400; // measured row height (estimate is 100) + +function originHarness(options = {}) { + return createHarness({ scrollAnchoring: "origin", ...options }); +} + +test("above-viewport first measurements absorb into the origin: no writes, viewport stable", () => { + const h = originHarness(); + h.emitScroll(9880, true); + + const firstVisible = h.virtualizer.getVirtualItems()[0]; + const visibleBefore = h.virtualizer + .getVirtualItems() + .filter((item) => item.index > firstVisible.index) + .map((item) => ({ key: item.key, start: item.start })); + const offsetBefore = h.virtualizer.scrollOffset; + + for (const index of [firstVisible.index - 2, firstVisible.index - 1, firstVisible.index]) { + h.virtualizer.resizeItem(index, ACTUAL); + } + + assert.equal(h.writes.length, 0, "origin mode must not write scrollTop mid-scroll"); + assert.equal(h.virtualizer.scrollOffset, offsetBefore); + assert.equal(h.originOffset(), -3 * (ACTUAL - 100)); + for (const { key, start } of visibleBefore) { + assert.equal(h.itemByKey(key).start, start, `row ${key} must not move`); + } + assert.equal(h.blankBandAtViewportTop(), 0); +}); + +test("the compensation policy still gates absorption", () => { + const h = originHarness(); + h.virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false; + h.emitScroll(9880, true); + + const firstVisible = h.virtualizer.getVirtualItems()[0]; + h.virtualizer.resizeItem(firstVisible.index - 1, ACTUAL); + + assert.equal(h.originOffset(), 0); + assert.equal(h.writes.length, 0); +}); + +test("prepends anchor through the origin with zero writes (anchorTo end)", () => { + const h = originHarness(); + h.emitScroll(9880, true); + h.emitScroll(9880, false); + h.writes.length = 0; + + const anchorBefore = h.itemByKey("row-98"); + const offsetBefore = h.virtualizer.scrollOffset; + + // Ten rows arrive above: same tail keys, shifted indexes, larger count. + const prepended = 10; + h.virtualizer.setOptions({ + ...h.virtualizer.options, + count: 200 + prepended, + getItemKey: (index) => (index < prepended ? `new-${index}` : `row-${index - prepended}`), + }); + h.virtualizer._willUpdate(); + + assert.equal(h.virtualizer.scrollOffset, offsetBefore, "scrollOffset must not move"); + assert.equal(h.writes.length, 0, "prepend must not write scrollTop"); + assert.equal( + h.itemByKey("row-98").start, + anchorBefore.start, + "the row under the viewport must keep its position", + ); + assert.equal(h.blankBandAtViewportTop(), 0); +}); + +test("idle away from the edges settles the debt with one verified write", () => { + const h = originHarness(); + h.emitScroll(9880, true); + + const firstVisible = h.virtualizer.getVirtualItems()[0]; + for (const index of [firstVisible.index - 1, firstVisible.index]) { + h.virtualizer.resizeItem(index, ACTUAL); + } + const debt = h.originOffset(); + assert.ok(debt < 0); + + // Still scrolling: no rebase (that would be the racy write again). + h.emitScroll(9760, true); + assert.equal(h.writes.length, 0); + assert.equal(h.originOffset(), debt); + + const anchor = h.virtualizer.getVirtualItems()[1]; + const relativeBefore = h.itemByKey(anchor.key).start - h.virtualizer.scrollOffset; + + // Scroll settles: one write settles the whole debt. + h.emitScroll(9760, false); + assert.equal(h.originOffset(), 0); + assert.equal(h.writes.length, 1); + assert.equal(h.writes[0].swallowed, false); + assert.equal( + h.itemByKey(anchor.key).start - h.virtualizer.scrollOffset, + relativeBefore, + "rebase must keep the viewport visually still", + ); + assert.equal(h.blankBandAtViewportTop(), 0); +}); + +test("debt past its budget forces a rebase even mid-scroll", () => { + const h = originHarness(); + h.emitScroll(9880, true); + + // Accumulate more than max(2*viewport, 2000) px of debt. + const firstVisible = h.virtualizer.getVirtualItems()[0]; + for (let step = 0; step < 8; step += 1) { + h.virtualizer.resizeItem(firstVisible.index - step, 400); + } + assert.ok(Math.abs(h.originOffset()) > 2000); + + h.emitScroll(9760, true); + assert.equal(h.originOffset(), 0); + assert.equal(h.writes.length, 1); + assert.equal(h.blankBandAtViewportTop(), 0); +}); + +test("approaching the top with debt forces a rebase before the broken zone", () => { + const h = originHarness({ initialOffset: 2000 }); + // Off row-boundary so the first visible row starts above the viewport top + // (the absorption predicate is itemStart < scrollOffset). + h.emitScroll(1850, true); + + const firstVisible = h.virtualizer.getVirtualItems()[0]; + h.virtualizer.resizeItem(firstVisible.index, ACTUAL); + assert.ok(h.originOffset() < 0); + + // Next tick lands within the near-top window (scrollOffset < 2*viewport + |debt|). + h.emitScroll(1300, true); + assert.equal(h.originOffset(), 0); + assert.equal(h.writes.length, 1); + assert.equal(h.blankBandAtViewportTop(), 0); +}); + +test("swallowed rebase writes self-heal through write verification", () => { + const h = originHarness(); + h.emitScroll(9880, true); + const firstVisible = h.virtualizer.getVirtualItems()[0]; + h.virtualizer.resizeItem(firstVisible.index, ACTUAL); + assert.ok(h.originOffset() < 0); + + h.setSwallowWrites(true); + h.emitScroll(9760, false); // idle -> rebase fires, write swallowed + h.setSwallowWrites(false); + + assert.equal(h.originOffset(), 0); + assert.ok(h.writes.at(-1)?.swallowed); + assert.ok(h.virtualizer.scrollOffset !== h.realScrollTop); + + h.runRafs(); // verification adopts the DOM truth + assert.equal(h.virtualizer.scrollOffset, h.realScrollTop); + assert.equal(h.blankBandAtViewportTop(), 0); +}); diff --git a/crates/virtual-core/test/write-verify.test.mjs b/crates/virtual-core/test/write-verify.test.mjs new file mode 100644 index 000000000..4e43cee97 --- /dev/null +++ b/crates/virtual-core/test/write-verify.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createHarness } from "./helpers/harness.mjs"; + +// Regression for the "blank band until the next scroll" bug: in 'offset' +// anchoring, applyScrollAdjustment eagerly folds the compensation delta into +// the scrollOffset mirror and issues a scrollTo. Compositor-scrolled +// viewports (WKWebView during an active wheel gesture) can silently swallow +// that write — no scroll event ever re-syncs the mirror, ranges are computed +// for a window the viewport never reached, and the visible viewport top is +// blank until the user scrolls again. The write-landing verification must +// detect the swallowed write one frame later and roll the mirror back. + +const ACTUAL = 400; // measured row height (estimate is 100) + +test("swallowed compensation writes self-heal within one frame", () => { + const h = createHarness(); + + // Wheel tick up: backward scroll into unmeasured territory. + h.emitScroll(9880, true); + assert.equal(h.blankBandAtViewportTop(), 0); + + // ResizeObserver batch: first measurements for the rows mounted at/above + // the viewport top. Each compensates via scrollTo — all swallowed. + const firstVisible = h.virtualizer.getVirtualItems()[0]; + h.setSwallowWrites(true); + for (const index of [firstVisible.index - 2, firstVisible.index - 1, firstVisible.index]) { + h.virtualizer.resizeItem(index, ACTUAL); + } + h.setSwallowWrites(false); + + // Mirror diverged; the rendered window sits below the real viewport. + assert.equal(h.writes.filter((w) => w.swallowed).length, 3); + assert.ok(h.virtualizer.scrollOffset > h.realScrollTop + 100); + assert.ok(h.blankBandAtViewportTop() > 0, "expected a blank band before verification"); + + // One frame later the verification reads the DOM back, adopts it, and + // recomputes the range — no user scroll needed. + h.runRafs(); + assert.equal(h.virtualizer.scrollOffset, h.realScrollTop); + assert.equal(h.blankBandAtViewportTop(), 0); +}); + +test("landed writes are left untouched by verification", () => { + const h = createHarness(); + h.emitScroll(9880, true); + + const firstVisible = h.virtualizer.getVirtualItems()[0]; + h.virtualizer.resizeItem(firstVisible.index, ACTUAL); + + const write = h.writes.at(-1); + assert.equal(write.swallowed, false); + assert.equal(h.virtualizer.scrollOffset, write.landed); + + const before = h.virtualizer.scrollOffset; + h.runRafs(); + assert.equal(h.virtualizer.scrollOffset, before); + assert.equal(h.blankBandAtViewportTop(), 0); +}); + +test("a scroll-event echo consumes the intent before the verify frame", () => { + const h = createHarness(); + h.emitScroll(9880, true); + + const firstVisible = h.virtualizer.getVirtualItems()[0]; + h.virtualizer.resizeItem(firstVisible.index, ACTUAL); + h.emitEcho(true); + + const before = h.virtualizer.scrollOffset; + h.runRafs(); + assert.equal(h.virtualizer.scrollOffset, before); + assert.equal(h.virtualizer.scrollOffset, h.realScrollTop); + assert.equal(h.blankBandAtViewportTop(), 0); +}); diff --git a/crates/virtual-core/tsconfig.json b/crates/virtual-core/tsconfig.json new file mode 100644 index 000000000..a5851ed1d --- /dev/null +++ b/crates/virtual-core/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/crates/virtual-core/types/index.d.ts b/crates/virtual-core/types/index.d.ts new file mode 100644 index 000000000..6febd9d55 --- /dev/null +++ b/crates/virtual-core/types/index.d.ts @@ -0,0 +1,209 @@ +// Hand-maintained public type surface for the vendored fork (consumers read +// this via the exports "types" condition and skipLibCheck it; the TS source +// in ../src is only type-checked by this package's own tsconfig). Keep new +// public options/methods in sync with ../src/index.ts. +export declare const _resetIOSDetectionForTests: () => void; +export { approxEqual, debounce, memo, notUndefined } from './utils.js'; +export type { NoInfer, PartialKeys } from './utils.js'; +type ScrollDirection = 'forward' | 'backward'; +type ScrollAlignment = 'start' | 'center' | 'end' | 'auto'; +type ScrollBehavior = 'auto' | 'smooth' | 'instant'; +type ScrollAnchor = 'start' | 'end'; +type FollowOnAppend = boolean | ScrollBehavior; +export interface ScrollToOptions { + align?: ScrollAlignment; + behavior?: ScrollBehavior; +} +type ScrollToOffsetOptions = ScrollToOptions; +type ScrollToIndexOptions = ScrollToOptions; +type ScrollToEndOptions = Pick; +export interface Range { + startIndex: number; + endIndex: number; + overscan: number; + count: number; +} +type Key = number | string | bigint; +export interface VirtualItem { + key: Key; + index: number; + start: number; + end: number; + size: number; + lane: number; +} +export interface Rect { + width: number; + height: number; +} +export declare const defaultKeyExtractor: (index: number) => number; +export declare const defaultRangeExtractor: (range: Range) => number[]; +export declare const observeElementRect: (instance: Virtualizer, cb: (rect: Rect) => void) => (() => void) | undefined; +export declare const observeWindowRect: (instance: Virtualizer, cb: (rect: Rect) => void) => (() => void) | undefined; +type ObserveOffsetCallBack = (offset: number, isScrolling: boolean) => void; +export declare const observeElementOffset: (instance: Virtualizer, cb: ObserveOffsetCallBack) => (() => void) | undefined; +export declare const observeWindowOffset: (instance: Virtualizer, cb: ObserveOffsetCallBack) => (() => void) | undefined; +export declare const measureElement: (element: TItemElement, entry: ResizeObserverEntry | undefined, instance: Virtualizer) => number; +export declare const windowScroll: (offset: number, options: { + adjustments?: number; + behavior?: ScrollBehavior; +}, instance: Virtualizer) => void; +export declare const elementScroll: (offset: number, options: { + adjustments?: number; + behavior?: ScrollBehavior; +}, instance: Virtualizer) => void; +type LaneAssignmentMode = 'estimate' | 'measured'; +export interface VirtualizerOptions { + count: number; + getScrollElement: () => TScrollElement | null; + estimateSize: (index: number) => number; + scrollToFn: (offset: number, options: { + adjustments?: number; + behavior?: ScrollBehavior; + }, instance: Virtualizer) => void; + observeElementRect: (instance: Virtualizer, cb: (rect: Rect) => void) => void | (() => void); + observeElementOffset: (instance: Virtualizer, cb: ObserveOffsetCallBack) => void | (() => void); + debug?: boolean; + initialRect?: Rect; + onChange?: (instance: Virtualizer, sync: boolean) => void; + measureElement?: (element: TItemElement, entry: ResizeObserverEntry | undefined, instance: Virtualizer) => number; + overscan?: number; + horizontal?: boolean; + paddingStart?: number; + paddingEnd?: number; + scrollPaddingStart?: number; + scrollPaddingEnd?: number; + initialOffset?: number | (() => number); + getItemKey?: (index: number) => Key; + rangeExtractor?: (range: Range) => Array; + scrollMargin?: number; + gap?: number; + indexAttribute?: string; + initialMeasurementsCache?: Array; + lanes?: number; + anchorTo?: ScrollAnchor; + followOnAppend?: FollowOnAppend; + /** + * How estimate→measured corrections for content above the viewport keep + * visible rows stable. 'offset' (default) writes the delta to scrollTop + * (upstream behavior); 'origin' absorbs it into a layout origin baked + * into row positions — no programmatic scroll can race the user's + * gesture — and settles the accumulated debt with one verified write at + * a safe moment (idle / debt cap / approaching the top). + */ + scrollAnchoring?: 'offset' | 'origin'; + /** + * Extends the visible window by this many pixels toward the user's last + * scroll direction so compositor-async scrolling reveals pre-rendered + * content. 0 disables. + */ + directionalOverscanPx?: number; + scrollEndThreshold?: number; + isScrollingResetDelay?: number; + useScrollendEvent?: boolean; + enabled?: boolean; + isRtl?: boolean; + useAnimationFrameWithResizeObserver?: boolean; + laneAssignmentMode?: LaneAssignmentMode; + useCachedMeasurements?: boolean; +} +export declare class Virtualizer { + private unsubs; + options: Required>; + scrollElement: TScrollElement | null; + targetWindow: (Window & typeof globalThis) | null; + isScrolling: boolean; + private scrollState; + measurementsCache: Array; + private _flatMeasurements; + itemSizeCache: Map; + private itemSizeCacheVersion; + private laneAssignments; + private pendingMin; + private prevLanes; + private lanesChangedFlag; + private lanesSettling; + private pendingScrollAnchor; + scrollRect: Rect | null; + scrollOffset: number | null; + scrollDirection: ScrollDirection | null; + private scrollAdjustments; + private _iosDeferredAdjustment; + private _iosTouching; + private _iosJustTouchEnded; + private _iosTouchEndTimerId; + private _intendedScrollOffset; + shouldAdjustScrollPositionOnItemSizeChange: undefined | ((item: VirtualItem, delta: number, instance: Virtualizer) => boolean); + elementsCache: Map; + private now; + private observer; + range: { + startIndex: number; + endIndex: number; + } | null; + constructor(opts: VirtualizerOptions); + setOptions: (opts: VirtualizerOptions) => void; + private notify; + private applyScrollAdjustment; + private maybeNotify; + private cleanup; + _didMount: () => () => void; + _willUpdate: () => void; + private _flushIosDeferredIfReady; + private rafId; + private scheduleScrollReconcile; + private reconcileScroll; + private getSize; + private getScrollOffset; + private getMeasurementOptions; + private getMeasurements; + calculateRange: { + (): { + startIndex: number; + endIndex: number; + } | null; + updateDeps(newDeps: [VirtualItem[], number, number, number]): void; + }; + getVirtualIndexes: { + (): number[]; + updateDeps(newDeps: [(range: Range) => Array, number, number, number | null, number | null]): void; + }; + indexFromElement: (node: TItemElement) => number; + /** + * Determines if an item at the given index should be measured during smooth scroll. + * During smooth scroll, only items within a buffer range around the target are measured + * to prevent items far from the target from pushing it away. + */ + private shouldMeasureDuringScroll; + measureElement: (node: TItemElement | null) => void; + resizeItem: (index: number, size: number) => void; + getVirtualItems: { + (): VirtualItem[]; + updateDeps(newDeps: [number[], VirtualItem[]]): void; + }; + getVirtualItemForOffset: (offset: number) => VirtualItem | undefined; + private getMaxScrollOffset; + private getVirtualDistanceFromEnd; + getDistanceFromEnd: () => number; + isAtEnd: (threshold?: number) => boolean; + getOffsetForAlignment: (toOffset: number, align: ScrollAlignment, itemSize?: number) => number; + getOffsetForIndex: (index: number, align?: ScrollAlignment) => readonly [number, "auto"] | readonly [number, "start" | "center" | "end"] | undefined; + scrollToOffset: (toOffset: number, { align, behavior }?: ScrollToOffsetOptions) => void; + scrollToIndex: (index: number, { align: initialAlign, behavior, }?: ScrollToIndexOptions) => void; + scrollBy: (delta: number, { behavior }?: ScrollToOffsetOptions) => void; + scrollToEnd: ({ behavior }?: ScrollToEndOptions) => void; + getTotalSize: () => number; + /** + * Returns a snapshot of currently-measured items suitable for round- + * tripping through state storage (sessionStorage, history, etc.) and + * passing back as `initialMeasurementsCache` on remount. Pair with the + * current `scrollOffset` to restore exact scroll position after navigation. + * + * Only items the consumer has actually rendered (and thus measured) appear + * in the snapshot; unmeasured items will fall back to `estimateSize` on + * restore. Returns an empty array if no items have been measured. + */ + takeSnapshot: () => Array; + private _scrollToOffset; + measure: () => void; +} diff --git a/crates/virtual-core/types/utils.d.ts b/crates/virtual-core/types/utils.d.ts new file mode 100644 index 000000000..8af53b8d3 --- /dev/null +++ b/crates/virtual-core/types/utils.d.ts @@ -0,0 +1,15 @@ +export type NoInfer = [A][A extends any ? 0 : never]; +export type PartialKeys = Omit & Partial>; +export declare function memo, TResult>(getDeps: () => [...TDeps], fn: (...args: NoInfer<[...TDeps]>) => TResult, opts: { + key: false | string; + debug?: () => boolean; + onChange?: (result: TResult) => void; + initialDeps?: TDeps; + skipInitialOnChange?: boolean; +}): { + (): TResult; + updateDeps(newDeps: [...TDeps]): void; +}; +export declare function notUndefined(value: T | undefined, msg?: string): T; +export declare const approxEqual: (a: number, b: number) => boolean; +export declare const debounce: (targetWindow: Window & typeof globalThis, fn: Function, ms: number) => (this: any, ...args: Array) => void; diff --git a/package.json b/package.json index 04113a9b9..f61851972 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "check:script-tests": "node --test scripts/ui-boundary-declarations.test.mjs scripts/run-node-tests.test.mjs scripts/check-biome-changed.test.mjs", "check:ui-boundaries": "node scripts/check-ui-boundaries.mjs", "typecheck:ui": "pnpm --filter @liveagent/ui typecheck", + "typecheck:virtual-core": "pnpm --filter @tanstack/virtual-core typecheck", + "test:virtual-core": "pnpm --filter @tanstack/virtual-core test", "lint:ui": "pnpm --filter @liveagent/ui lint", "lint:gui": "pnpm --filter liveagent lint", "lint:webui": "pnpm --filter @liveagent/gateway-webui lint", @@ -33,6 +35,7 @@ "@iconify-json/logos": "1.2.11", "@iconify-json/lucide": "1.2.108", "@tanstack/react-virtual": "3.14.6", + "@tanstack/virtual-core": "workspace:*", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41d2c995c..e8aa5ea25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,7 @@ overrides: '@iconify-json/logos': 1.2.11 '@iconify-json/lucide': 1.2.108 '@tanstack/react-virtual': 3.14.6 + '@tanstack/virtual-core': workspace:* '@types/react': 19.2.14 '@types/react-dom': 19.2.3 '@vitejs/plugin-react': 6.0.1 @@ -387,6 +388,12 @@ importers: specifier: ~7.0.2 version: 7.0.2 + crates/virtual-core: + devDependencies: + typescript: + specifier: ~7.0.2 + version: 7.0.2 + packages: '@alloc/quick-lru@5.2.0': @@ -1387,9 +1394,6 @@ packages: react: 19.2.4 react-dom: 19.2.4 - '@tanstack/virtual-core@3.17.4': - resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==} - '@tauri-apps/api@2.11.1': resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} @@ -4439,12 +4443,10 @@ snapshots: '@tanstack/react-virtual@3.14.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/virtual-core': 3.17.4 + '@tanstack/virtual-core': link:crates/virtual-core react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@tanstack/virtual-core@3.17.4': {} - '@tauri-apps/api@2.11.1': {} '@tauri-apps/cli-darwin-arm64@2.11.4': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 12d447165..c3fc766e4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - "crates/agent-ui" - "crates/agent-gui" - "crates/agent-gateway/web" + - "crates/virtual-core" allowBuilds: "@google/genai": true diff --git a/scripts/check.mjs b/scripts/check.mjs index 67fb1b331..7ca852220 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -72,6 +72,8 @@ function buildSteps() { miseStep("Check script tests", "pnpm", ["check:script-tests"]), miseStep("Shared UI boundaries", "pnpm", ["check:ui-boundaries"]), miseStep("Shared UI TypeScript check", "pnpm", ["typecheck:ui"]), + miseStep("Virtual core TypeScript check", "pnpm", ["typecheck:virtual-core"]), + miseStep("Virtual core tests", "pnpm", ["test:virtual-core"]), miseStep("GUI TypeScript and Vite build", "pnpm", ["build:gui"]), miseStep("WebUI TypeScript and Vite build", "pnpm", ["build:webui"]), miseStep("Tauri Rust check", "cargo", ["check", "--workspace", "--tests"]), From eb3bcd2a7afc79d13d0342f6bb27660cb9914694 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sat, 22 Aug 2026 10:03:01 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(chat):=20=E4=B8=A4=E7=AB=AF=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=88=97=E8=A1=A8=E5=90=AF=E7=94=A8=20origin=20?= =?UTF-8?q?=E9=94=9A=E5=AE=9A=E4=B8=8E=E6=B5=8B=E9=87=8F=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GUI/WebUI 会话虚拟列表启用 scrollAnchoring=origin(滚动中零程序化 写入)与 480px 方向性 overscan(合成器先行滚动时露出预渲染内容而非 空白); - 行高测量 LRU 支持 localStorage 持久化(按宿主命名空间),重访会话跨 重启直接以精确行高布局,消解首滚估高误差这一补偿源头;损坏/超额存 储静默降级为纯内存,新增持久化回归测试。 Co-authored-by: Cursor --- .../web/src/components/GatewayTranscript.tsx | 16 ++- .../web/test/measurements-lru.test.mjs | 2 +- .../pages/chat/transcript/TranscriptList.tsx | 18 ++- .../test/chat/measurements-lru.test.mjs | 67 +++++++++- .../lib/transcript-virtual/measurementsLru.ts | 117 ++++++++++++++++-- 5 files changed, 201 insertions(+), 19 deletions(-) diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index 36ba462bb..5d9a7d0c3 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -132,8 +132,11 @@ const TRANSCRIPT_ROW_GAP = 18; // Measured row heights survive conversation switches: saved on unmount, // restored (width-gated) on the next open so the switch lays out with exact -// heights instead of estimates. -const transcriptMeasurementsLru = createTranscriptMeasurementsLru(); +// heights instead of estimates. Persisted so revisited conversations skip +// the estimate→measure correction churn across page reloads too. +const transcriptMeasurementsLru = createTranscriptMeasurementsLru({ + persistNamespace: "webui-transcript", +}); type GatewayTranscriptVirtualItem = | { key: string; kind: "loadRemoteHistory" } @@ -673,6 +676,15 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr // virtualizer's bottom correction and leaves live growth to useScrollFollow. anchorTo: viewportFollowing ? "start" : "end", scrollEndThreshold: 8, + // Above-viewport estimate corrections and history-page prepends are + // absorbed into the layout origin instead of written to scrollTop, so + // no programmatic scroll can race the user's wheel gesture; the debt + // settles with one verified write when scrolling is idle. + scrollAnchoring: "origin", + // Compositors paint scrolls ahead of the main thread; keep roughly half + // a viewport of pre-rendered rows toward the scroll direction so fast + // wheel ticks reveal content instead of blank space. + directionalOverscanPx: 480, initialMeasurementsCache, rangeExtractor: extractTranscriptRange, }); diff --git a/crates/agent-gateway/web/test/measurements-lru.test.mjs b/crates/agent-gateway/web/test/measurements-lru.test.mjs index d7ce33774..74e907530 100644 --- a/crates/agent-gateway/web/test/measurements-lru.test.mjs +++ b/crates/agent-gateway/web/test/measurements-lru.test.mjs @@ -138,7 +138,7 @@ test("empty snapshots, blank ids, and blank layout keys are not stored", () => { }); test("capacity evicts the least recently used entry", () => { - const lru = createTranscriptMeasurementsLru(2); + const lru = createTranscriptMeasurementsLru({ capacity: 2 }); lru.save("conv-1", layoutKey(800, 768), [item("a", 1)]); lru.save("conv-2", layoutKey(800, 768), [item("b", 2)]); // Touch conv-1 so conv-2 becomes the eviction candidate. diff --git a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx index eec9c0213..caff6d84e 100644 --- a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx @@ -48,8 +48,11 @@ function buildVersionedTranscriptLayoutKey(viewportWidth: number, contentWidth: // Measured row heights survive conversation switches: saved on unmount, // restored (width-gated) on the next open so the switch lays out with exact -// heights instead of estimates. -const transcriptMeasurementsLru = createTranscriptMeasurementsLru(); +// heights instead of estimates. Persisted so revisited conversations skip +// the estimate→measure correction churn across app restarts too. +const transcriptMeasurementsLru = createTranscriptMeasurementsLru({ + persistNamespace: "gui-transcript", +}); const SummaryCard = memo(function SummaryCard(props: { item: RenderSummaryCard }) { const { item } = props; @@ -223,6 +226,17 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList // virtualizer's bottom correction and leaves live growth to useScrollFollow. anchorTo: viewportFollowing ? "start" : "end", scrollEndThreshold: 8, + // Above-viewport estimate corrections are absorbed into the layout + // origin instead of written to scrollTop: on WKWebView the compositor + // owns the viewport during a wheel gesture and can silently swallow + // programmatic scrolls, leaving the virtualizer rendering a window the + // viewport never reached (a blank band until the next scroll). The debt + // settles with one verified write when scrolling is idle. + scrollAnchoring: "origin", + // WKWebView paints compositor scrolls ahead of the main thread; keep + // roughly a half viewport of pre-rendered rows toward the scroll + // direction so fast wheel ticks reveal content instead of blank space. + directionalOverscanPx: 480, rangeExtractor: extractVirtualRange, }); diff --git a/crates/agent-gui/test/chat/measurements-lru.test.mjs b/crates/agent-gui/test/chat/measurements-lru.test.mjs index ad0b4ea75..f9d3637d1 100644 --- a/crates/agent-gui/test/chat/measurements-lru.test.mjs +++ b/crates/agent-gui/test/chat/measurements-lru.test.mjs @@ -136,7 +136,7 @@ test("empty snapshots, blank ids, and blank layout keys are not stored", () => { }); test("capacity evicts the least recently used entry", () => { - const lru = createTranscriptMeasurementsLru(2); + const lru = createTranscriptMeasurementsLru({ capacity: 2 }); lru.save("conv-1", layoutKey(800, 768), [item("a", 1)]); lru.save("conv-2", layoutKey(800, 768), [item("b", 2)]); // Touch conv-1 so conv-2 becomes the eviction candidate. @@ -155,3 +155,68 @@ test("re-saving a conversation replaces its snapshot", () => { assert.equal(lru.restore("conv-1", layoutKey(800, 768)), null); assert.equal(lru.restore("conv-1", layoutKey(820, 960)), next); }); + +function withFakeLocalStorage(run) { + const store = new Map(); + const previous = globalThis.localStorage; + globalThis.localStorage = { + getItem: (key) => (store.has(key) ? store.get(key) : null), + setItem: (key, value) => { + store.set(key, String(value)); + }, + removeItem: (key) => { + store.delete(key); + }, + }; + try { + run(store); + } finally { + if (previous === undefined) { + delete globalThis.localStorage; + } else { + globalThis.localStorage = previous; + } + } +} + +test("persisted snapshots round-trip across LRU instances (app restarts)", () => { + withFakeLocalStorage(() => { + const first = createTranscriptMeasurementsLru({ persistNamespace: "test" }); + first.save("conv-1", layoutKey(800, 768), [item("a", 120), item("b", 300)]); + + const second = createTranscriptMeasurementsLru({ persistNamespace: "test" }); + const restored = second.restore("conv-1", layoutKey(800, 768)); + assert.equal(restored.length, 2); + assert.equal(restored[0].key, "a"); + assert.equal(restored[0].size, 120); + // Layout gating still applies to persisted entries. + assert.equal(second.restore("conv-1", layoutKey(900, 768)), null); + }); +}); + +test("malformed persisted payloads degrade to an empty cache", () => { + withFakeLocalStorage((store) => { + const probe = createTranscriptMeasurementsLru({ persistNamespace: "test" }); + probe.save("conv-1", layoutKey(800, 768), [item("a", 120)]); + const [persistKey] = [...store.keys()]; + store.set(persistKey, "{not json"); + + const lru = createTranscriptMeasurementsLru({ persistNamespace: "test" }); + assert.equal(lru.restore("conv-1", layoutKey(800, 768)), null); + // The cache still works (memory-only) after the failed read. + lru.save("conv-2", layoutKey(800, 768), [item("b", 60)]); + assert.ok(lru.restore("conv-2", layoutKey(800, 768))); + }); +}); + +test("storage write failures degrade to memory-only", () => { + withFakeLocalStorage(() => { + globalThis.localStorage.setItem = () => { + throw new Error("quota exceeded"); + }; + const lru = createTranscriptMeasurementsLru({ persistNamespace: "test" }); + const measurements = [item("a", 120)]; + lru.save("conv-1", layoutKey(800, 768), measurements); + assert.equal(lru.restore("conv-1", layoutKey(800, 768)), measurements); + }); +}); diff --git a/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts b/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts index d095ab0f9..488e89525 100644 --- a/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts +++ b/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts @@ -4,15 +4,25 @@ import type { VirtualItem } from "@tanstack/react-virtual"; // taken on unmount (virtualizer.takeSnapshot()) and fed back through // initialMeasurementsCache when the conversation reopens — switching back to // a conversation then lays out with exact row heights instead of estimates. -// In-memory only and layout-gated: measured heights depend on both the scroll -// viewport and the centered transcript width, so callers provide a composite -// layout key. Snapshots are never persisted. +// Layout-gated: measured heights depend on both the scroll viewport and the +// centered transcript width, so callers provide a composite layout key. +// With `persistNamespace` set, snapshots also round-trip through +// localStorage, so revisited conversations skip the estimate→measure +// correction churn (the fuel for scroll-compensation work) across restarts. export type TranscriptMeasurementsLru = { save: (conversationId: string, layoutKey: string, measurements: VirtualItem[]) => void; restore: (conversationId: string, layoutKey: string) => VirtualItem[] | null; }; +export type TranscriptMeasurementsLruOptions = { + capacity?: number; + // Persist snapshots under this namespace in localStorage. Omit for the + // previous in-memory-only behavior. Failures (quota, disabled storage, + // malformed payloads) silently degrade to memory-only. + persistNamespace?: string; +}; + // The composite key both callers must use. Owning the shape here keeps the two // frontends from drifting, and returning "" for an unmeasured viewport or // column re-establishes the guard the plain-number key used to carry: save() @@ -24,32 +34,113 @@ export function buildTranscriptLayoutKey(viewportWidth: number, contentWidth: nu } const DEFAULT_CAPACITY = 12; +// Giant transcripts are cheap to re-measure relative to their storage cost; +// skip persisting them rather than risk quota churn. +const PERSIST_MAX_ROWS_PER_ENTRY = 5000; +const PERSIST_VERSION = 1; + +type StoredEntry = { layoutKey: string; measurements: VirtualItem[] }; + +function persistKeyFor(namespace: string) { + return `liveagent.transcript-measurements.v${PERSIST_VERSION}.${namespace}`; +} + +function isPlainVirtualItem(value: unknown): value is VirtualItem { + if (typeof value !== "object" || value === null) return false; + const item = value as Record; + return ( + (typeof item.key === "string" || typeof item.key === "number") && + typeof item.index === "number" && + Number.isFinite(item.start) && + Number.isFinite(item.size) && + Number.isFinite(item.end) && + typeof item.lane === "number" + ); +} + +function readPersistedEntries(namespace: string): Map { + const entries = new Map(); + try { + if (typeof localStorage === "undefined") return entries; + const raw = localStorage.getItem(persistKeyFor(namespace)); + if (!raw) return entries; + const parsed = JSON.parse(raw) as { entries?: unknown }; + if (!Array.isArray(parsed.entries)) return entries; + for (const pair of parsed.entries) { + if (!Array.isArray(pair) || pair.length !== 2) continue; + const [conversationId, entry] = pair as [unknown, Partial | null]; + if (typeof conversationId !== "string" || !entry) continue; + if (typeof entry.layoutKey !== "string" || !Array.isArray(entry.measurements)) continue; + if (!entry.measurements.every(isPlainVirtualItem)) continue; + entries.set(conversationId, { + layoutKey: entry.layoutKey, + measurements: entry.measurements, + }); + } + } catch { + entries.clear(); + } + return entries; +} + +function writePersistedEntries(namespace: string, entries: Map) { + try { + if (typeof localStorage === "undefined") return; + localStorage.setItem( + persistKeyFor(namespace), + JSON.stringify({ entries: [...entries.entries()] }), + ); + } catch { + // Quota or serialization failure: memory-only from here on is fine. + } +} export function createTranscriptMeasurementsLru( - capacity = DEFAULT_CAPACITY, + options: TranscriptMeasurementsLruOptions = {}, ): TranscriptMeasurementsLru { - const entries = new Map(); + const capacity = options.capacity ?? DEFAULT_CAPACITY; + const persistNamespace = options.persistNamespace; + let entries: Map | null = null; + + // Lazy so module-level singletons don't pay the localStorage read until a + // transcript actually mounts. + const getEntries = () => { + if (entries === null) { + entries = persistNamespace ? readPersistedEntries(persistNamespace) : new Map(); + while (entries.size > capacity) { + const oldest = entries.keys().next().value; + if (oldest === undefined) break; + entries.delete(oldest); + } + } + return entries; + }; return { save: (conversationId, layoutKey, measurements) => { if (!conversationId || !layoutKey || measurements.length === 0) { return; } - entries.delete(conversationId); - entries.set(conversationId, { layoutKey, measurements }); - while (entries.size > capacity) { - const oldest = entries.keys().next().value; + const map = getEntries(); + map.delete(conversationId); + map.set(conversationId, { layoutKey, measurements }); + while (map.size > capacity) { + const oldest = map.keys().next().value; if (oldest === undefined) break; - entries.delete(oldest); + map.delete(oldest); + } + if (persistNamespace && measurements.length <= PERSIST_MAX_ROWS_PER_ENTRY) { + writePersistedEntries(persistNamespace, map); } }, restore: (conversationId, layoutKey) => { - const hit = entries.get(conversationId); + const map = getEntries(); + const hit = map.get(conversationId); if (!hit || hit.layoutKey !== layoutKey) { return null; } - entries.delete(conversationId); - entries.set(conversationId, hit); + map.delete(conversationId); + map.set(conversationId, hit); return hit.measurements; }, }; From ed7142783760c9496458e201d51b466f25e8bf99 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sun, 23 Aug 2026 21:57:15 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(virtual):=20=E5=A4=8D=E5=AE=A1=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20iOS=20=E6=89=8B=E5=8A=BF=E6=9C=9F=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=20rebase=20=E7=AB=9E=E6=80=81,=E5=8E=8B=E7=BC=A9=E6=B5=8B?= =?UTF-8?q?=E9=87=8F=E6=8C=81=E4=B9=85=E5=8C=96=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS 动量滚动期间 nearTop/overCap 强制 rebase 会先平移布局, 而配套的 scrollTop 写入被 iOS 延迟分支吞掉,布局与镜像短暂错位 并产生二次跳动;改为手势完全落定后再 rebase(touchend 宽限计时 器到期时补触发一次)。持久化层只存 [key, size] 二元组,localStorage 配额余量约提升 4 倍。 Co-authored-by: Cursor --- .../test/chat/measurements-lru.test.mjs | 10 ++++ .../lib/transcript-virtual/measurementsLru.ts | 55 +++++++++++++------ crates/virtual-core/src/index.ts | 17 ++++++ .../test/origin-anchoring.test.mjs | 43 +++++++++++++++ 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/crates/agent-gui/test/chat/measurements-lru.test.mjs b/crates/agent-gui/test/chat/measurements-lru.test.mjs index f9d3637d1..4868a2fc8 100644 --- a/crates/agent-gui/test/chat/measurements-lru.test.mjs +++ b/crates/agent-gui/test/chat/measurements-lru.test.mjs @@ -194,6 +194,16 @@ test("persisted snapshots round-trip across LRU instances (app restarts)", () => }); }); +test("persisted payload stores compact [key, size] rows, not full items", () => { + withFakeLocalStorage((store) => { + const lru = createTranscriptMeasurementsLru({ persistNamespace: "test" }); + lru.save("conv-1", layoutKey(800, 768), [item("a", 120)]); + const [raw] = [...store.values()]; + const parsed = JSON.parse(raw); + assert.deepEqual(parsed.entries[0][1].rows, [["a", 120]]); + }); +}); + test("malformed persisted payloads degrade to an empty cache", () => { withFakeLocalStorage((store) => { const probe = createTranscriptMeasurementsLru({ persistNamespace: "test" }); diff --git a/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts b/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts index 488e89525..e21cbbe89 100644 --- a/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts +++ b/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts @@ -41,23 +41,35 @@ const PERSIST_VERSION = 1; type StoredEntry = { layoutKey: string; measurements: VirtualItem[] }; +// The virtualizer only consumes `key` and `size` from a restored snapshot +// (positions are always recomputed from sizes), so the persisted form keeps +// just those two per row — roughly 4x the quota headroom of full items. +type PersistedRow = [key: string | number, size: number]; + function persistKeyFor(namespace: string) { return `liveagent.transcript-measurements.v${PERSIST_VERSION}.${namespace}`; } -function isPlainVirtualItem(value: unknown): value is VirtualItem { - if (typeof value !== "object" || value === null) return false; - const item = value as Record; +function isPersistedRow(value: unknown): value is PersistedRow { return ( - (typeof item.key === "string" || typeof item.key === "number") && - typeof item.index === "number" && - Number.isFinite(item.start) && - Number.isFinite(item.size) && - Number.isFinite(item.end) && - typeof item.lane === "number" + Array.isArray(value) && + value.length === 2 && + (typeof value[0] === "string" || typeof value[0] === "number") && + Number.isFinite(value[1]) ); } +function toVirtualItems(rows: PersistedRow[]): VirtualItem[] { + return rows.map(([key, size], index) => ({ + index, + key, + start: 0, + size, + end: size, + lane: 0, + })); +} + function readPersistedEntries(namespace: string): Map { const entries = new Map(); try { @@ -68,13 +80,16 @@ function readPersistedEntries(namespace: string): Map { if (!Array.isArray(parsed.entries)) return entries; for (const pair of parsed.entries) { if (!Array.isArray(pair) || pair.length !== 2) continue; - const [conversationId, entry] = pair as [unknown, Partial | null]; + const [conversationId, entry] = pair as [ + unknown, + { layoutKey?: unknown; rows?: unknown } | null, + ]; if (typeof conversationId !== "string" || !entry) continue; - if (typeof entry.layoutKey !== "string" || !Array.isArray(entry.measurements)) continue; - if (!entry.measurements.every(isPlainVirtualItem)) continue; + if (typeof entry.layoutKey !== "string" || !Array.isArray(entry.rows)) continue; + if (!entry.rows.every(isPersistedRow)) continue; entries.set(conversationId, { layoutKey: entry.layoutKey, - measurements: entry.measurements, + measurements: toVirtualItems(entry.rows), }); } } catch { @@ -86,10 +101,16 @@ function readPersistedEntries(namespace: string): Map { function writePersistedEntries(namespace: string, entries: Map) { try { if (typeof localStorage === "undefined") return; - localStorage.setItem( - persistKeyFor(namespace), - JSON.stringify({ entries: [...entries.entries()] }), - ); + const persisted = [...entries.entries()].map(([conversationId, entry]) => [ + conversationId, + { + layoutKey: entry.layoutKey, + rows: entry.measurements.map( + (item): PersistedRow => [item.key as string | number, item.size], + ), + }, + ]); + localStorage.setItem(persistKeyFor(namespace), JSON.stringify({ entries: persisted })); } catch { // Quota or serialization failure: memory-only from here on is fine. } diff --git a/crates/virtual-core/src/index.ts b/crates/virtual-core/src/index.ts index bf0cc6073..bcedf5c2b 100644 --- a/crates/virtual-core/src/index.ts +++ b/crates/virtual-core/src/index.ts @@ -1028,6 +1028,10 @@ export class Virtualizer< // After the grace window, attempt to flush. The scroll event // for momentum decay may have already fired before our timer. this._flushIosDeferredIfReady() + // Same for origin debt: if the last scroll event fired inside + // the grace window, its rebase attempt was deferred and no + // further scroll event will come — settle it now. + this.maybeRebaseOrigin() }, 150) } scrollEl.addEventListener( @@ -1772,6 +1776,19 @@ export class Virtualizer< if (this.originOffset === 0) return // Programmatic scrolls own their own convergence loop. if (this.scrollState) return + // A rebase must shift the layout and write scrollTop in the same pass. + // On iOS mid-gesture the write would be deferred (writing scrollTop + // cancels the momentum), which would leave the already-shifted layout + // and the un-adjusted offset visibly inconsistent until the deferred + // flush — a double jump. Wait for the gesture to settle instead; scroll + // events keep arriving through momentum decay, so the idle rebase below + // still runs promptly. + if ( + isIOSWebKit() && + (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) + ) { + return + } const size = this.getSize() const nearTop = this.getScrollOffset() < size * 2 + Math.abs(this.originOffset) diff --git a/crates/virtual-core/test/origin-anchoring.test.mjs b/crates/virtual-core/test/origin-anchoring.test.mjs index c4ac773eb..b7102d33b 100644 --- a/crates/virtual-core/test/origin-anchoring.test.mjs +++ b/crates/virtual-core/test/origin-anchoring.test.mjs @@ -143,6 +143,49 @@ test("approaching the top with debt forces a rebase before the broken zone", () assert.equal(h.blankBandAtViewportTop(), 0); }); +test("on iOS, forced rebases wait for the gesture to settle", () => { + // A rebase must shift the layout and write scrollTop in the same pass. On + // iOS mid-gesture the write is deferred (it would cancel the momentum), so + // rebasing there would leave the shifted layout visibly inconsistent until + // the deferred flush — the rebase itself must wait for settle instead. + const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "iPhone", platform: "iPhone", maxTouchPoints: 5 }, + configurable: true, + }); + const h = originHarness(); + h.core._resetIOSDetectionForTests(); + try { + h.emitScroll(9880, true); + + // Accumulate over-budget debt: on desktop this forces a mid-scroll rebase. + const firstVisible = h.virtualizer.getVirtualItems()[0]; + for (let step = 0; step < 8; step += 1) { + h.virtualizer.resizeItem(firstVisible.index - step, 400); + } + const debt = h.originOffset(); + assert.ok(Math.abs(debt) > 2000); + + h.emitScroll(9760, true); + assert.equal(h.originOffset(), debt, "mid-gesture rebase must be deferred on iOS"); + assert.equal(h.writes.length, 0); + + // Gesture settles: the rebase runs with one direct write. + h.emitScroll(9760, false); + assert.equal(h.originOffset(), 0); + assert.equal(h.writes.length, 1); + assert.equal(h.writes[0].swallowed, false); + assert.equal(h.blankBandAtViewportTop(), 0); + } finally { + if (previousNavigator) { + Object.defineProperty(globalThis, "navigator", previousNavigator); + } else { + delete globalThis.navigator; + } + h.core._resetIOSDetectionForTests(); + } +}); + test("swallowed rebase writes self-heal through write verification", () => { const h = originHarness(); h.emitScroll(9880, true); From bb7c33b1e0487a76844dfd9188e470f31a087afa Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sun, 23 Aug 2026 23:09:32 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(docker):=20WebUI=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E8=A1=A5=E6=8B=B7=E8=B4=9D=E6=94=B6=E7=BC=96?= =?UTF-8?q?=E7=9A=84=20virtual-core=20workspace=20=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @tanstack/virtual-core 经 pnpm overrides 指向 workspace 后, 镜像内缺少该包导致 react-virtual 的类型再导出全部失效, Gateway Docker Smoke 在 tsc 阶段失败。 Co-authored-by: Cursor --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile b/Dockerfile index fa4f76fff..addb85323 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,11 +6,15 @@ WORKDIR /src RUN npm install -g pnpm@10.32.1 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY crates/virtual-core/package.json crates/virtual-core/package.json COPY crates/agent-ui/package.json crates/agent-ui/package.json COPY crates/agent-gui/package.json crates/agent-gui/package.json COPY crates/agent-gateway/web/package.json crates/agent-gateway/web/package.json RUN pnpm install --frozen-lockfile --filter @liveagent/gateway-webui... +# The vendored @tanstack/virtual-core ships TypeScript source (exports point +# at src/ and types/), so the build stage needs the whole package. +COPY crates/virtual-core crates/virtual-core COPY crates/agent-ui crates/agent-ui COPY crates/agent-gateway/web crates/agent-gateway/web RUN pnpm --filter @liveagent/gateway-webui build From bf78043695cc89233c7f285c7c731d88c2bdcad1 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 23 Aug 2026 23:57:59 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(virtual):=20rebase=20=E5=86=99=E5=85=A5?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E5=8C=96=E5=B9=B6=E5=AF=B9=20WebKit=20?= =?UTF-8?q?=E5=85=A8=E7=B3=BB=E6=8E=A8=E8=BF=9F=E6=89=8B=E5=8A=BF=E6=9C=9F?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E7=BB=93=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审发现 origin 锚定在目标平台上留有一个会复现 #606 的洞: maybeRebaseOrigin 只对 iOS 推迟手势期结算,macOS WKWebView 在 nearTop/overCap 强制路径下仍会滚动中写 scrollTop;写入被合成器 吞掉时 verifyScrollWriteLanded 只回滚 scrollOffset 镜像,已平移 的布局不回滚——可见行相对视口跳 |debt| 像素,先露白一帧再错位。 - 手势期强制 rebase 的推迟从 isIOSWebKit 扩大到整个 WebKit 家族 (macOS WKWebView/桌面 Safari 同样会吞主线程滚动写入);落定后 的 rebase 写入能可靠落地,视觉上是无操作。Blink/Gecko 尊重手势 期写入,强制路径保持原行为; - rebase 改为事务:记录结算的债务与写前偏移,滚动事件回声或校验 帧确认视口实际位移后结清;未被确认的部分回归 originOffset, 布局与镜像在同一趟内一起回滚,吞写/截断退化为"缩小的 rebase" 而非跳动,债务留待下一个安全时机结算; - 新增回归测试:WebKit 手势期推迟、滚动中吞写整帧回滚+重试自愈, 并按新契约收紧原吞写自愈测试(断言行位置逐像素不动)。 另修复两处持久化边角: - 超过 5000 行的快照此前直接跳过 localStorage 写入,旧的过期快照 会存活到下次重启被还原;改为写入时过滤超额条目,顺带清掉陈旧 副本(新增回归测试); - WebUI 持久化 layout key 补上行模型版本(对齐 GUI 的版本化 key), 避免行结构跨版本变化后错误行高穿透刷新继续污染布局。 Co-authored-by: Cursor --- .../web/src/components/GatewayTranscript.tsx | 15 ++- .../test/chat/measurements-lru.test.mjs | 23 ++++ .../lib/transcript-virtual/measurementsLru.ts | 26 ++-- crates/virtual-core/src/index.ts | 124 ++++++++++++++--- .../test/origin-anchoring.test.mjs | 126 +++++++++++++++++- 5 files changed, 280 insertions(+), 34 deletions(-) diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index 5d9a7d0c3..f4e0151d3 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -130,6 +130,17 @@ export type GatewayTranscriptNavHandle = TranscriptNavigationHandle; const TRANSCRIPT_ROW_ESTIMATED_HEIGHT = 260; const TRANSCRIPT_ROW_GAP = 18; +// Bump when the transcript row model or its measurement semantics change: +// persisted snapshots outlive releases, and stale heights keyed only by +// widths would seed wrong layouts (and scroll-compensation churn) after an +// upgrade. Mirrors the GUI's versioned key. +const TRANSCRIPT_MEASUREMENT_LAYOUT_VERSION = "gateway-rows-v1"; + +function buildVersionedTranscriptLayoutKey(viewportWidth: number, contentWidth: number) { + const layoutKey = buildTranscriptLayoutKey(viewportWidth, contentWidth); + return layoutKey ? `${layoutKey}:${TRANSCRIPT_MEASUREMENT_LAYOUT_VERSION}` : ""; +} + // Measured row heights survive conversation switches: saved on unmount, // restored (width-gated) on the next open so the switch lays out with exact // heights instead of estimates. Persisted so revisited conversations skip @@ -655,7 +666,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr (conversationId && scrollViewport ? transcriptMeasurementsLru.restore( conversationId, - buildTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth), + buildVersionedTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth), ) : null) ?? [], ); @@ -796,7 +807,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr if (!conversationId || !scrollViewport) return; transcriptMeasurementsLru.save( conversationId, - buildTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth), + buildVersionedTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth), transcriptVirtualizer.takeSnapshot(), ); }; diff --git a/crates/agent-gui/test/chat/measurements-lru.test.mjs b/crates/agent-gui/test/chat/measurements-lru.test.mjs index 4868a2fc8..e02ca5b9a 100644 --- a/crates/agent-gui/test/chat/measurements-lru.test.mjs +++ b/crates/agent-gui/test/chat/measurements-lru.test.mjs @@ -230,3 +230,26 @@ test("storage write failures degrade to memory-only", () => { assert.equal(lru.restore("conv-1", layoutKey(800, 768)), measurements); }); }); + +test("oversized snapshots skip persistence and prune their stale persisted copy", () => { + withFakeLocalStorage(() => { + const first = createTranscriptMeasurementsLru({ persistNamespace: "test" }); + first.save("conv-1", layoutKey(800, 768), [item("a", 120)]); + first.save("conv-2", layoutKey(800, 768), [item("b", 60)]); + + // conv-1 grows past the per-entry cap: memory keeps serving it, but the + // persisted copy must not stay frozen at the old (now stale) snapshot. + const oversized = Array.from({ length: 5001 }, (_, i) => item(`row-${i}`, 40)); + first.save("conv-1", layoutKey(800, 768), oversized); + assert.equal(first.restore("conv-1", layoutKey(800, 768)), oversized); + + const second = createTranscriptMeasurementsLru({ persistNamespace: "test" }); + assert.equal( + second.restore("conv-1", layoutKey(800, 768)), + null, + "a restart must not resurrect the pre-growth snapshot", + ); + // Small entries in the same namespace survive the oversized save. + assert.equal(second.restore("conv-2", layoutKey(800, 768)).length, 1); + }); +}); diff --git a/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts b/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts index e21cbbe89..b6fd2e12d 100644 --- a/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts +++ b/crates/agent-ui/src/lib/transcript-virtual/measurementsLru.ts @@ -35,7 +35,9 @@ export function buildTranscriptLayoutKey(viewportWidth: number, contentWidth: nu const DEFAULT_CAPACITY = 12; // Giant transcripts are cheap to re-measure relative to their storage cost; -// skip persisting them rather than risk quota churn. +// exclude them from the persisted payload rather than risk quota churn. The +// write itself still runs so a previously persisted (now stale) snapshot of +// the same conversation is pruned instead of surviving into the next restart. const PERSIST_MAX_ROWS_PER_ENTRY = 5000; const PERSIST_VERSION = 1; @@ -101,15 +103,17 @@ function readPersistedEntries(namespace: string): Map { function writePersistedEntries(namespace: string, entries: Map) { try { if (typeof localStorage === "undefined") return; - const persisted = [...entries.entries()].map(([conversationId, entry]) => [ - conversationId, - { - layoutKey: entry.layoutKey, - rows: entry.measurements.map( - (item): PersistedRow => [item.key as string | number, item.size], - ), - }, - ]); + const persisted = [...entries.entries()] + .filter(([, entry]) => entry.measurements.length <= PERSIST_MAX_ROWS_PER_ENTRY) + .map(([conversationId, entry]) => [ + conversationId, + { + layoutKey: entry.layoutKey, + rows: entry.measurements.map( + (item): PersistedRow => [item.key as string | number, item.size], + ), + }, + ]); localStorage.setItem(persistKeyFor(namespace), JSON.stringify({ entries: persisted })); } catch { // Quota or serialization failure: memory-only from here on is fine. @@ -150,7 +154,7 @@ export function createTranscriptMeasurementsLru( if (oldest === undefined) break; map.delete(oldest); } - if (persistNamespace && measurements.length <= PERSIST_MAX_ROWS_PER_ENTRY) { + if (persistNamespace) { writePersistedEntries(persistNamespace, map); } }, diff --git a/crates/virtual-core/src/index.ts b/crates/virtual-core/src/index.ts index bcedf5c2b..9f34ba726 100644 --- a/crates/virtual-core/src/index.ts +++ b/crates/virtual-core/src/index.ts @@ -17,9 +17,26 @@ const isIOSWebKit = (): boolean => { navigator.platform === 'MacIntel' && mtp !== undefined && mtp > 0) } -// Test hook: reset the iOS detection cache. Not exported. +// WebKit-family detection beyond iOS. macOS WKWebView (Tauri), desktop +// Safari, and WebKitGTK share the compositor behavior this file works +// around: during an active wheel/trackpad gesture the compositor owns the +// viewport and a main-thread scrollTop write can be silently swallowed. +// Blink/Gecko honor mid-gesture programmatic scrolls, so they are excluded +// (their UAs carry "AppleWebKit" for legacy reasons only). +let _isWebKitResult: boolean | undefined +const isWebKit = (): boolean => { + if (_isWebKitResult !== undefined) return _isWebKitResult + if (isIOSWebKit()) return (_isWebKitResult = true) + if (typeof navigator === 'undefined') return (_isWebKitResult = false) + const ua = navigator.userAgent + return (_isWebKitResult = + /AppleWebKit/.test(ua) && !/Chrome|Chromium|Edg\/|OPR\/|Android/.test(ua)) +} + +// Test hook: reset the browser detection caches. Not exported. export const _resetIOSDetectionForTests = () => { _isIOSResult = undefined + _isWebKitResult = undefined } export { approxEqual, debounce, memo, notUndefined } from './utils' @@ -482,6 +499,16 @@ export class Virtualizer< // intent and the DOM disagrees, the DOM wins: roll the mirror back and // recompute. Scroll position has exactly one source of truth — the DOM. private _writeVerifyRafId: number | null = null + // In-flight rebase transaction ('origin' anchoring). A rebase must move + // the layout and the viewport by the same amount in one pass; the layout + // shift is synchronous but the scrollTop write can be swallowed or + // clamped. Until the write is confirmed (scroll-event echo or the verify + // frame), remember the settled debt and the pre-write offset so the + // unconfirmed remainder can be put back into the origin instead of being + // dropped — dropping it detaches the shifted layout from the unmoved + // viewport, which reads as a jump through the transcript. + private _pendingRebaseDebt: number | null = null + private _pendingRebasePreWriteOffset = 0 shouldAdjustScrollPositionOnItemSizeChange: | undefined | (( @@ -827,14 +854,27 @@ export class Virtualizer< if (real === null) return // Same tolerance the scroll-event reconciliation uses for subpixel // rounding of our own writes. - if (Math.abs(real - this._intendedScrollOffset) < 1.5) return + if (Math.abs(real - this._intendedScrollOffset) < 1.5) { + // Landed. Any pending rebase transaction is fully settled (zero + // residual); the echo will consume the intent as usual. + this.resolveRebaseTransaction(real) + return + } // The write was swallowed (compositor gesture) or clamped (sizer not // grown yet) and produced no scroll event that could re-sync us. Adopt // the DOM's value and recompute the range so rendering matches the real - // viewport again — without waiting for the user's next scroll. + // viewport again — without waiting for the user's next scroll. If the + // write belonged to a rebase, the unconfirmed part of the debt returns + // to the origin so the already-shifted layout moves back in the same + // pass — rows keep their position relative to the real viewport. this._intendedScrollOffset = null + const rebaseLayoutRolledBack = this.resolveRebaseTransaction(real) this.scrollOffset = real - this.maybeNotify() + if (rebaseLayoutRolledBack) { + this.notify(false) + } else { + this.maybeNotify() + } } private maybeNotify = memo( @@ -889,8 +929,10 @@ export class Virtualizer< this._iosJustTouchEnded = false // Origin debt is scoped to the current scroll element's DOM scroll // position; carrying it to a re-attached element would misplace every - // row by the stale debt. + // row by the stale debt. Same for an in-flight rebase transaction. this.originOffset = 0 + this._pendingRebaseDebt = null + this._pendingRebasePreWriteOffset = 0 this.lastScrollDirection = null this.scrollElement = null this.targetWindow = null @@ -958,13 +1000,21 @@ export class Virtualizer< // self-write — by the time the user has moved 1.5 px, the // intended value will already have been consumed by a prior // scroll event and cleared. - if ( - this._intendedScrollOffset !== null && - Math.abs(offset - this._intendedScrollOffset) < 1.5 - ) { - offset = this._intendedScrollOffset + if (this._intendedScrollOffset !== null) { + if (Math.abs(offset - this._intendedScrollOffset) < 1.5) { + offset = this._intendedScrollOffset + } + this._intendedScrollOffset = null + // A scroll event consumed the intent, so treat any in-flight + // rebase as landed. Rolling back from here would misattribute + // concurrent user movement as a swallowed write (freezing + // visible content for a frame on engines that honor mid-gesture + // writes). The genuinely-swallowed case produces no scroll + // event at all and is settled by the verify frame instead — + // and on WebKit, where swallowing happens, rebase writes are + // only issued once the gesture has settled. + this._pendingRebaseDebt = null } - this._intendedScrollOffset = null this.scrollAdjustments = 0 // If the offset hasn't moved, this is the echo of our own @@ -1766,6 +1816,29 @@ export class Virtualizer< this.itemSizeCacheVersion++ } + // Settle an in-flight rebase transaction against an observed DOM offset. + // The rebase shifted the layout by `debt` px and asked the viewport to + // follow; `observed - preWriteOffset` is how far the viewport actually + // moved. Any unconfirmed remainder returns to the origin so layout and + // viewport stay in agreement — a swallowed or clamped write degrades to a + // smaller rebase instead of detaching the shifted layout from the unmoved + // viewport (a visible jump through the transcript). The returned debt + // settles on a later, verified attempt. Returns true when row positions + // changed and the consumer must re-render. + private resolveRebaseTransaction = (observedOffset: number): boolean => { + if (this._pendingRebaseDebt === null) return false + const residual = + this._pendingRebaseDebt - + (observedOffset - this._pendingRebasePreWriteOffset) + this._pendingRebaseDebt = null + // Same tolerance as the subpixel write reconciliation. + if (Math.abs(residual) < 1.5) return false + this.originOffset -= residual + this.pendingMin = 0 + this.itemSizeCacheVersion++ + return true + } + // Settle the accumulated origin debt with one verified scrollTop write. // Deferred while the gesture owns the viewport; forced when the debt // exceeds its budget or the viewport approaches the mis-positioned zone @@ -1777,14 +1850,18 @@ export class Virtualizer< // Programmatic scrolls own their own convergence loop. if (this.scrollState) return // A rebase must shift the layout and write scrollTop in the same pass. - // On iOS mid-gesture the write would be deferred (writing scrollTop - // cancels the momentum), which would leave the already-shifted layout - // and the un-adjusted offset visibly inconsistent until the deferred - // flush — a double jump. Wait for the gesture to settle instead; scroll - // events keep arriving through momentum decay, so the idle rebase below - // still runs promptly. + // Mid-gesture that write is unsafe across the whole WebKit family: on + // iOS it would be deferred (writing scrollTop cancels the momentum), + // and on macOS WKWebView / desktop Safari the compositor owns the + // viewport during a wheel gesture and can silently swallow the write — + // the already-shifted layout would detach from the real viewport, the + // exact jump this mode exists to remove. Wait for the gesture to + // settle instead: scroll events keep arriving through momentum decay, + // so the idle rebase below still runs promptly, and a settle-time + // write that lands is visually a no-op. Blink/Gecko honor mid-gesture + // writes, so the forced paths below stay live there. if ( - isIOSWebKit() && + isWebKit() && (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) ) { return @@ -1804,6 +1881,7 @@ export class Virtualizer< // so keep waiting — scrolling away from the bottom settles it. return } + const preWriteOffset = this.getScrollOffset() const delta = -this.originOffset this.originOffset = 0 this.pendingMin = 0 @@ -1811,6 +1889,11 @@ export class Virtualizer< // Mirror + DOM write + write-landing verification; then re-render with // the rebased layout and offset in one consistent pass. this.applyScrollAdjustment(delta) + // Arm the transaction after the write (issuing a write clears any stale + // transaction): until a scroll event or the verify frame confirms the + // viewport followed, the settled debt is provisional. + this._pendingRebaseDebt = delta + this._pendingRebasePreWriteOffset = preWriteOffset this.notify(false) } @@ -2120,6 +2203,11 @@ export class Virtualizer< behavior: ScrollBehavior | undefined }, ) => { + // A new write supersedes any in-flight rebase transaction — its + // observation channels can no longer attribute the DOM's movement to + // the old write. (The rebase path re-arms its transaction right after + // issuing its own write.) + this._pendingRebaseDebt = null // Record the intended logical scroll target so the next scroll event // can reconcile against subpixel rounding by the browser. this._intendedScrollOffset = offset + (adjustments ?? 0) diff --git a/crates/virtual-core/test/origin-anchoring.test.mjs b/crates/virtual-core/test/origin-anchoring.test.mjs index b7102d33b..ed9315a39 100644 --- a/crates/virtual-core/test/origin-anchoring.test.mjs +++ b/crates/virtual-core/test/origin-anchoring.test.mjs @@ -186,22 +186,142 @@ test("on iOS, forced rebases wait for the gesture to settle", () => { } }); +test("on macOS WKWebView (desktop WebKit), forced rebases wait for the gesture to settle", () => { + // During a wheel gesture the WKWebView compositor owns the viewport and + // can silently swallow a main-thread scrollTop write. A mid-gesture rebase + // whose write is swallowed detaches the already-shifted layout from the + // real viewport — the exact jump 'origin' mode exists to remove — so even + // over-budget debt must wait for settle, where a landed rebase write is + // visually a no-op. + const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { + value: { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)", + platform: "MacIntel", + maxTouchPoints: 0, + }, + configurable: true, + }); + const h = originHarness(); + h.core._resetIOSDetectionForTests(); + try { + h.emitScroll(9880, true); + + const firstVisible = h.virtualizer.getVirtualItems()[0]; + for (let step = 0; step < 8; step += 1) { + h.virtualizer.resizeItem(firstVisible.index - step, 400); + } + const debt = h.originOffset(); + assert.ok(Math.abs(debt) > 2000); + + h.emitScroll(9760, true); + assert.equal(h.originOffset(), debt, "mid-gesture rebase must be deferred on WebKit"); + assert.equal(h.writes.length, 0); + + const anchor = h.virtualizer.getVirtualItems()[1]; + const relativeBefore = h.itemByKey(anchor.key).start - h.realScrollTop; + + // Gesture settles: the rebase runs with one landed write, invisibly. + h.emitScroll(9760, false); + assert.equal(h.originOffset(), 0); + assert.equal(h.writes.length, 1); + assert.equal(h.writes[0].swallowed, false); + assert.equal( + h.itemByKey(anchor.key).start - h.realScrollTop, + relativeBefore, + "a settle-time rebase must keep the viewport visually still", + ); + assert.equal(h.blankBandAtViewportTop(), 0); + } finally { + if (previousNavigator) { + Object.defineProperty(globalThis, "navigator", previousNavigator); + } else { + delete globalThis.navigator; + } + h.core._resetIOSDetectionForTests(); + } +}); + +test("a swallowed forced rebase rolls its layout shift back within one frame", () => { + // Engines that honor mid-gesture writes (default harness navigator) keep + // the forced rebase paths; if such a write is still swallowed, the verify + // frame must return the unconfirmed debt to the origin so the layout + // re-attaches to the unmoved viewport — no jump, debt settles later. + const h = originHarness(); + h.emitScroll(9880, true); + + const firstVisible = h.virtualizer.getVirtualItems()[0]; + for (let step = 0; step < 8; step += 1) { + h.virtualizer.resizeItem(firstVisible.index - step, 400); + } + const debt = h.originOffset(); + assert.ok(Math.abs(debt) > 2000); + + const anchor = h.virtualizer.getVirtualItems()[2]; + const startBefore = h.itemByKey(anchor.key).start; + + // Over-budget debt forces a mid-scroll rebase; the write is swallowed. + h.setSwallowWrites(true); + h.emitScroll(9760, true); + h.setSwallowWrites(false); + assert.ok(h.writes.at(-1)?.swallowed); + assert.equal(h.originOffset(), 0, "the rebase provisionally settled the debt"); + assert.ok(h.virtualizer.scrollOffset !== h.realScrollTop, "mirror diverged from the DOM"); + + // The verify frame rolls the whole transaction back: mirror re-adopts the + // DOM, the origin takes the debt back, rows return to their exact + // pre-rebase positions. + h.runRafs(); + assert.equal(h.virtualizer.scrollOffset, h.realScrollTop); + assert.equal(h.originOffset(), debt, "unconfirmed debt must return to the origin"); + assert.equal( + h.itemByKey(anchor.key).start, + startBefore, + "a rolled-back rebase must not move any row", + ); + assert.equal(h.blankBandAtViewportTop(), 0); + + // A later attempt with a landing write settles the debt invisibly. + const relativeBefore = h.itemByKey(anchor.key).start - h.realScrollTop; + h.emitScroll(9760, false); + assert.equal(h.originOffset(), 0); + assert.equal(h.writes.at(-1)?.swallowed, false); + assert.equal( + h.itemByKey(anchor.key).start - h.realScrollTop, + relativeBefore, + "the retried rebase must keep the viewport visually still", + ); + assert.equal(h.blankBandAtViewportTop(), 0); +}); + test("swallowed rebase writes self-heal through write verification", () => { const h = originHarness(); h.emitScroll(9880, true); const firstVisible = h.virtualizer.getVirtualItems()[0]; h.virtualizer.resizeItem(firstVisible.index, ACTUAL); - assert.ok(h.originOffset() < 0); + const debt = h.originOffset(); + assert.ok(debt < 0); + const anchor = h.virtualizer.getVirtualItems()[1]; + const startBefore = h.itemByKey(anchor.key).start; h.setSwallowWrites(true); h.emitScroll(9760, false); // idle -> rebase fires, write swallowed h.setSwallowWrites(false); - assert.equal(h.originOffset(), 0); assert.ok(h.writes.at(-1)?.swallowed); assert.ok(h.virtualizer.scrollOffset !== h.realScrollTop); - h.runRafs(); // verification adopts the DOM truth + // Verification adopts the DOM truth and rolls the layout shift back. + h.runRafs(); assert.equal(h.virtualizer.scrollOffset, h.realScrollTop); + assert.equal(h.originOffset(), debt); + assert.equal(h.itemByKey(anchor.key).start, startBefore); + assert.equal(h.blankBandAtViewportTop(), 0); + + // The next safe moment settles the debt for real. + h.emitScroll(9760, false); + assert.equal(h.originOffset(), 0); + assert.equal(h.writes.at(-1)?.swallowed, false); assert.equal(h.blankBandAtViewportTop(), 0); }); From 96e00b8efbfbbd518ca72f6cb85a5a6e1cb98daf Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 24 Aug 2026 01:56:27 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(deps):=20=E8=AE=A9=20local-pkg=20?= =?UTF-8?q?=E8=83=BD=E8=A7=A3=E6=9E=90=20@svgr/core=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Vite=20=E5=9B=BE=E6=A0=87=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unplugin-icons 经 local-pkg 的 import('@svgr/core') 编译 ~icons/*。 Node ESM 从 local-pkg 自身路径解析该包名,pnpm 又未把它提升到仓库 根 node_modules,Vite 8 worker 因此报 ERR_MODULE_NOT_FOUND。 - 用 packageExtensions 把 @svgr/core / plugin-jsx 挂到 local-pkg 旁; - .npmrc 提升 @svgr/* 供向上查找兜底; - overrides 同步到 pnpm-workspace.yaml,避免 pnpm 11 忽略 package.json#pnpm 后丢掉 virtual-core workspace 覆盖。 Co-authored-by: Cursor --- .npmrc | 6 ++++++ pnpm-lock.yaml | 20 +++++++++++++++----- pnpm-workspace.yaml | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..babf17786 --- /dev/null +++ b/.npmrc @@ -0,0 +1,6 @@ +# unplugin-icons loads the JSX compiler via local-pkg's +# `import('@svgr/core')`. Node ESM resolves that bare specifier from +# local-pkg's own file (under node_modules/.pnpm/...), not from the +# workspace package that lists the dependency. Hoist the SVGR packages +# to the workspace root so that walk-up resolution succeeds. +public-hoist-pattern[]=*@svgr/* diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8aa5ea25..0b2ee55c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,8 @@ overrides: unplugin-icons: 23.0.1 vite: 8.0.5 +packageExtensionsChecksum: sha256-MBJdTWKlgmpDNBnkOYQU7ic+VmL95TyVwwdh53gPN1A= + importers: .: @@ -165,7 +167,7 @@ importers: version: 7.0.2 unplugin-icons: specifier: 23.0.1 - version: 23.0.1(@svgr/core@8.1.0(typescript@7.0.2)) + version: 23.0.1(@svgr/core@8.1.0(typescript@7.0.2))(typescript@7.0.2) vite: specifier: 8.0.5 version: 8.0.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) @@ -304,7 +306,7 @@ importers: version: 7.0.2 unplugin-icons: specifier: 23.0.1 - version: 23.0.1(@svgr/core@8.1.0(typescript@7.0.2)) + version: 23.0.1(@svgr/core@8.1.0(typescript@7.0.2))(typescript@7.0.2) vite: specifier: 8.0.5 version: 8.0.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) @@ -5593,11 +5595,16 @@ snapshots: lines-and-columns@1.2.4: {} - local-pkg@1.2.1: + local-pkg@1.2.1(typescript@7.0.2): dependencies: + '@svgr/core': 8.1.0(typescript@7.0.2) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@7.0.2)) mlly: 1.8.2 pkg-types: 2.3.1 quansync: 0.2.11 + transitivePeerDependencies: + - supports-color + - typescript lodash-es@4.18.1: {} @@ -6611,15 +6618,18 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - unplugin-icons@23.0.1(@svgr/core@8.1.0(typescript@7.0.2)): + unplugin-icons@23.0.1(@svgr/core@8.1.0(typescript@7.0.2))(typescript@7.0.2): dependencies: '@antfu/install-pkg': 1.1.0 '@iconify/utils': 3.1.4 - local-pkg: 1.2.1 + local-pkg: 1.2.1(typescript@7.0.2) obug: 2.1.4 unplugin: 2.3.11 optionalDependencies: '@svgr/core': 8.1.0(typescript@7.0.2) + transitivePeerDependencies: + - supports-color + - typescript unplugin@2.3.11: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c3fc766e4..8335dc3ed 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,41 @@ packages: - "crates/agent-gateway/web" - "crates/virtual-core" +# pnpm 11+ reads these from this file and ignores package.json#pnpm. +# Keep the same pins as package.json so CI (pnpm 10) and local (11) agree. +overrides: + "@base-ui/react": "1.6.0" + "@biomejs/biome": "2.4.15" + "@bufbuild/protobuf": "2.12.1" + "@git-diff-view/file": "0.1.3" + "@git-diff-view/react": "0.1.3" + "@iconify-json/gravity-ui": "1.2.12" + "@iconify-json/logos": "1.2.11" + "@iconify-json/lucide": "1.2.108" + "@tanstack/react-virtual": "3.14.6" + "@tanstack/virtual-core": "workspace:*" + "@types/react": "19.2.14" + "@types/react-dom": "19.2.3" + "@vitejs/plugin-react": "6.0.1" + katex: "0.18.4" + monaco-editor: "0.56.0" + postcss: "8.5.8" + react: "19.2.4" + react-dom: "19.2.4" + unplugin-icons: "23.0.1" + vite: "8.0.5" + allowBuilds: "@google/genai": true esbuild: true protobufjs: true + +# local-pkg's importModule() does `import(name)` from its own file, so a +# bare `@svgr/core` must exist next to local-pkg, not only in the app +# package that declared it. Without this, Vite 8 / Node 22+ ESM workers +# fail with ERR_MODULE_NOT_FOUND when compiling ~icons/* to JSX. +packageExtensions: + local-pkg: + dependencies: + "@svgr/core": "8.1.0" + "@svgr/plugin-jsx": "8.1.0"