@tanstack/virtual-core
Advanced tools
| "use strict"; | ||
| Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); | ||
| function createLazyMeasurementsView(count, flat, getItemKey) { | ||
| const cache = new Array(count); | ||
| return new Proxy(cache, { | ||
| get(target, prop, receiver) { | ||
| if (typeof prop === "string") { | ||
| 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); | ||
| } | ||
| }); | ||
| } | ||
| exports.createLazyMeasurementsView = createLazyMeasurementsView; | ||
| //# sourceMappingURL=lazy-measurements.cjs.map |
| {"version":3,"file":"lazy-measurements.cjs","sources":["../../src/lazy-measurements.ts"],"sourcesContent":["// Lazy materialization for the lanes===1 fast path. Backed by a\n// Float64Array (stride 2: start, size, …); VirtualItems are constructed on\n// first indexed read and cached. Saves the per-item object allocation at\n// large list counts where most items are never visible.\n\nimport type { VirtualItem } from './index'\n\ntype Key = number | string | bigint\n\nexport function createLazyMeasurementsView(\n count: number,\n flat: Float64Array,\n getItemKey: (i: number) => Key,\n): Array<VirtualItem> {\n const cache: Array<VirtualItem | undefined> = new Array(count)\n return new Proxy(cache as any, {\n get(target, prop, receiver) {\n if (typeof prop === 'string') {\n // Cheap digit-prefix sniff before number coerce.\n const c = prop.charCodeAt(0)\n if (c >= 48 && c <= 57) {\n const i = +prop\n if (Number.isInteger(i) && i >= 0 && i < count) {\n let v = target[i]\n if (!v) {\n const s = flat[i * 2]!\n v = target[i] = {\n index: i,\n key: getItemKey(i),\n start: s,\n size: flat[i * 2 + 1]!,\n end: s + flat[i * 2 + 1]!,\n lane: 0,\n }\n }\n return v\n }\n }\n if (prop === 'length') return count\n }\n return Reflect.get(target, prop, receiver)\n },\n }) as Array<VirtualItem>\n}\n"],"names":[],"mappings":";;AASO,SAAS,2BACd,OACA,MACA,YACoB;AACpB,QAAM,QAAwC,IAAI,MAAM,KAAK;AAC7D,SAAO,IAAI,MAAM,OAAc;AAAA,IAC7B,IAAI,QAAQ,MAAM,UAAU;AAC1B,UAAI,OAAO,SAAS,UAAU;AAE5B,cAAM,IAAI,KAAK,WAAW,CAAC;AAC3B,YAAI,KAAK,MAAM,KAAK,IAAI;AACtB,gBAAM,IAAI,CAAC;AACX,cAAI,OAAO,UAAU,CAAC,KAAK,KAAK,KAAK,IAAI,OAAO;AAC9C,gBAAI,IAAI,OAAO,CAAC;AAChB,gBAAI,CAAC,GAAG;AACN,oBAAM,IAAI,KAAK,IAAI,CAAC;AACpB,kBAAI,OAAO,CAAC,IAAI;AAAA,gBACd,OAAO;AAAA,gBACP,KAAK,WAAW,CAAC;AAAA,gBACjB,OAAO;AAAA,gBACP,MAAM,KAAK,IAAI,IAAI,CAAC;AAAA,gBACpB,KAAK,IAAI,KAAK,IAAI,IAAI,CAAC;AAAA,gBACvB,MAAM;AAAA,cAAA;AAAA,YAEV;AACA,mBAAO;AAAA,UACT;AAAA,QACF;AACA,YAAI,SAAS,SAAU,QAAO;AAAA,MAChC;AACA,aAAO,QAAQ,IAAI,QAAQ,MAAM,QAAQ;AAAA,IAC3C;AAAA,EAAA,CACD;AACH;;"} |
| import { VirtualItem } from './index.cjs'; | ||
| type Key = number | string | bigint; | ||
| export declare function createLazyMeasurementsView(count: number, flat: Float64Array, getItemKey: (i: number) => Key): Array<VirtualItem>; | ||
| export {}; |
| import { VirtualItem } from './index.js'; | ||
| type Key = number | string | bigint; | ||
| export declare function createLazyMeasurementsView(count: number, flat: Float64Array, getItemKey: (i: number) => Key): Array<VirtualItem>; | ||
| export {}; |
| function createLazyMeasurementsView(count, flat, getItemKey) { | ||
| const cache = new Array(count); | ||
| return new Proxy(cache, { | ||
| get(target, prop, receiver) { | ||
| if (typeof prop === "string") { | ||
| 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); | ||
| } | ||
| }); | ||
| } | ||
| export { | ||
| createLazyMeasurementsView | ||
| }; | ||
| //# sourceMappingURL=lazy-measurements.js.map |
| {"version":3,"file":"lazy-measurements.js","sources":["../../src/lazy-measurements.ts"],"sourcesContent":["// Lazy materialization for the lanes===1 fast path. Backed by a\n// Float64Array (stride 2: start, size, …); VirtualItems are constructed on\n// first indexed read and cached. Saves the per-item object allocation at\n// large list counts where most items are never visible.\n\nimport type { VirtualItem } from './index'\n\ntype Key = number | string | bigint\n\nexport function createLazyMeasurementsView(\n count: number,\n flat: Float64Array,\n getItemKey: (i: number) => Key,\n): Array<VirtualItem> {\n const cache: Array<VirtualItem | undefined> = new Array(count)\n return new Proxy(cache as any, {\n get(target, prop, receiver) {\n if (typeof prop === 'string') {\n // Cheap digit-prefix sniff before number coerce.\n const c = prop.charCodeAt(0)\n if (c >= 48 && c <= 57) {\n const i = +prop\n if (Number.isInteger(i) && i >= 0 && i < count) {\n let v = target[i]\n if (!v) {\n const s = flat[i * 2]!\n v = target[i] = {\n index: i,\n key: getItemKey(i),\n start: s,\n size: flat[i * 2 + 1]!,\n end: s + flat[i * 2 + 1]!,\n lane: 0,\n }\n }\n return v\n }\n }\n if (prop === 'length') return count\n }\n return Reflect.get(target, prop, receiver)\n },\n }) as Array<VirtualItem>\n}\n"],"names":[],"mappings":"AASO,SAAS,2BACd,OACA,MACA,YACoB;AACpB,QAAM,QAAwC,IAAI,MAAM,KAAK;AAC7D,SAAO,IAAI,MAAM,OAAc;AAAA,IAC7B,IAAI,QAAQ,MAAM,UAAU;AAC1B,UAAI,OAAO,SAAS,UAAU;AAE5B,cAAM,IAAI,KAAK,WAAW,CAAC;AAC3B,YAAI,KAAK,MAAM,KAAK,IAAI;AACtB,gBAAM,IAAI,CAAC;AACX,cAAI,OAAO,UAAU,CAAC,KAAK,KAAK,KAAK,IAAI,OAAO;AAC9C,gBAAI,IAAI,OAAO,CAAC;AAChB,gBAAI,CAAC,GAAG;AACN,oBAAM,IAAI,KAAK,IAAI,CAAC;AACpB,kBAAI,OAAO,CAAC,IAAI;AAAA,gBACd,OAAO;AAAA,gBACP,KAAK,WAAW,CAAC;AAAA,gBACjB,OAAO;AAAA,gBACP,MAAM,KAAK,IAAI,IAAI,CAAC;AAAA,gBACpB,KAAK,IAAI,KAAK,IAAI,IAAI,CAAC;AAAA,gBACvB,MAAM;AAAA,cAAA;AAAA,YAEV;AACA,mBAAO;AAAA,UACT;AAAA,QACF;AACA,YAAI,SAAS,SAAU,QAAO;AAAA,MAChC;AACA,aAAO,QAAQ,IAAI,QAAQ,MAAM,QAAQ;AAAA,IAC3C;AAAA,EAAA,CACD;AACH;"} |
| // 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<VirtualItem> { | ||
| const cache: Array<VirtualItem | undefined> = 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<VirtualItem> | ||
| } |
+267
-106
| "use strict"; | ||
| Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); | ||
| const lazyMeasurements = require("./lazy-measurements.cjs"); | ||
| const utils = require("./utils.cjs"); | ||
| let _isIOSResult; | ||
| const isIOSWebKit = () => { | ||
| if (_isIOSResult !== void 0) return _isIOSResult; | ||
| if (typeof navigator === "undefined") return _isIOSResult = false; | ||
| if (/iP(hone|od|ad)/.test(navigator.userAgent)) return _isIOSResult = true; | ||
| const mtp = navigator.maxTouchPoints; | ||
| return _isIOSResult = navigator.platform === "MacIntel" && mtp !== void 0 && mtp > 0; | ||
| }; | ||
| const _resetIOSDetectionForTests = () => { | ||
| _isIOSResult = void 0; | ||
| }; | ||
| const getRect = (element) => { | ||
@@ -12,5 +24,6 @@ const { offsetWidth, offsetHeight } = element; | ||
| const end = Math.min(range.endIndex + range.overscan, range.count - 1); | ||
| const arr = []; | ||
| for (let i = start; i <= end; i++) { | ||
| arr.push(i); | ||
| const len = end - start + 1; | ||
| const arr = new Array(len); | ||
| for (let i = 0; i < len; i++) { | ||
| arr[i] = start + i; | ||
| } | ||
@@ -74,3 +87,3 @@ return arr; | ||
| const supportsScrollend = typeof window == "undefined" ? true : "onscrollend" in window; | ||
| const observeElementOffset = (instance, cb) => { | ||
| const observeOffset = (instance, cb, readOffset) => { | ||
| const element = instance.scrollElement; | ||
@@ -84,50 +97,12 @@ if (!element) { | ||
| } | ||
| let offset = 0; | ||
| const fallback = instance.options.useScrollendEvent && supportsScrollend ? () => void 0 : utils.debounce( | ||
| targetWindow, | ||
| () => { | ||
| cb(offset, false); | ||
| }, | ||
| instance.options.isScrollingResetDelay | ||
| ); | ||
| const createHandler = (isScrolling) => () => { | ||
| const { horizontal, isRtl } = instance.options; | ||
| offset = horizontal ? element["scrollLeft"] * (isRtl && -1 || 1) : element["scrollTop"]; | ||
| fallback(); | ||
| cb(offset, isScrolling); | ||
| }; | ||
| const handler = createHandler(true); | ||
| const endHandler = createHandler(false); | ||
| element.addEventListener("scroll", handler, addEventListenerOptions); | ||
| const registerScrollendEvent = instance.options.useScrollendEvent && supportsScrollend; | ||
| if (registerScrollendEvent) { | ||
| element.addEventListener("scrollend", endHandler, addEventListenerOptions); | ||
| } | ||
| return () => { | ||
| element.removeEventListener("scroll", handler); | ||
| if (registerScrollendEvent) { | ||
| element.removeEventListener("scrollend", endHandler); | ||
| } | ||
| }; | ||
| }; | ||
| const observeWindowOffset = (instance, cb) => { | ||
| const element = instance.scrollElement; | ||
| if (!element) { | ||
| return; | ||
| } | ||
| const targetWindow = instance.targetWindow; | ||
| if (!targetWindow) { | ||
| return; | ||
| } | ||
| let offset = 0; | ||
| const fallback = instance.options.useScrollendEvent && supportsScrollend ? () => void 0 : utils.debounce( | ||
| const fallback = registerScrollendEvent ? null : utils.debounce( | ||
| targetWindow, | ||
| () => { | ||
| cb(offset, false); | ||
| }, | ||
| () => cb(offset, false), | ||
| instance.options.isScrollingResetDelay | ||
| ); | ||
| const createHandler = (isScrolling) => () => { | ||
| offset = element[instance.options.horizontal ? "scrollX" : "scrollY"]; | ||
| fallback(); | ||
| offset = readOffset(element); | ||
| fallback == null ? void 0 : fallback(); | ||
| cb(offset, isScrolling); | ||
@@ -138,3 +113,2 @@ }; | ||
| element.addEventListener("scroll", handler, addEventListenerOptions); | ||
| const registerScrollendEvent = instance.options.useScrollendEvent && supportsScrollend; | ||
| if (registerScrollendEvent) { | ||
@@ -150,2 +124,11 @@ element.addEventListener("scrollend", endHandler, addEventListenerOptions); | ||
| }; | ||
| const observeElementOffset = (instance, cb) => observeOffset(instance, cb, (el) => { | ||
| const { horizontal, isRtl } = instance.options; | ||
| return horizontal ? el.scrollLeft * (isRtl && -1 || 1) : el.scrollTop; | ||
| }); | ||
| const observeWindowOffset = (instance, cb) => observeOffset( | ||
| instance, | ||
| cb, | ||
| (win) => instance.options.horizontal ? win.scrollX : win.scrollY | ||
| ); | ||
| const measureElement = (element, entry, instance) => { | ||
@@ -163,3 +146,3 @@ if (entry == null ? void 0 : entry.borderBoxSize) { | ||
| }; | ||
| const windowScroll = (offset, { | ||
| const scrollWithAdjustments = (offset, { | ||
| adjustments = 0, | ||
@@ -169,19 +152,9 @@ behavior | ||
| var _a, _b; | ||
| const toOffset = offset + adjustments; | ||
| (_b = (_a = instance.scrollElement) == null ? void 0 : _a.scrollTo) == null ? void 0 : _b.call(_a, { | ||
| [instance.options.horizontal ? "left" : "top"]: toOffset, | ||
| [instance.options.horizontal ? "left" : "top"]: offset + adjustments, | ||
| behavior | ||
| }); | ||
| }; | ||
| const elementScroll = (offset, { | ||
| adjustments = 0, | ||
| behavior | ||
| }, instance) => { | ||
| var _a, _b; | ||
| const toOffset = offset + adjustments; | ||
| (_b = (_a = instance.scrollElement) == null ? void 0 : _a.scrollTo) == null ? void 0 : _b.call(_a, { | ||
| [instance.options.horizontal ? "left" : "top"]: toOffset, | ||
| behavior | ||
| }); | ||
| }; | ||
| const windowScroll = scrollWithAdjustments; | ||
| const elementScroll = scrollWithAdjustments; | ||
| class Virtualizer { | ||
@@ -195,5 +168,7 @@ constructor(opts) { | ||
| this.measurementsCache = []; | ||
| this._flatMeasurements = null; | ||
| this.itemSizeCache = /* @__PURE__ */ new Map(); | ||
| this.itemSizeCacheVersion = 0; | ||
| this.laneAssignments = /* @__PURE__ */ new Map(); | ||
| this.pendingMeasuredCacheIndexes = []; | ||
| this.pendingMin = null; | ||
| this.prevLanes = void 0; | ||
@@ -206,2 +181,7 @@ this.lanesChangedFlag = false; | ||
| this.scrollAdjustments = 0; | ||
| this._iosDeferredAdjustment = 0; | ||
| this._iosTouching = false; | ||
| this._iosJustTouchEnded = false; | ||
| this._iosTouchEndTimerId = null; | ||
| this._intendedScrollOffset = null; | ||
| this.elementsCache = /* @__PURE__ */ new Map(); | ||
@@ -228,2 +208,8 @@ this.now = () => { | ||
| this.observer.unobserve(node); | ||
| for (const [cacheKey, cachedNode] of this.elementsCache) { | ||
| if (cachedNode === node) { | ||
| this.elementsCache.delete(cacheKey); | ||
| break; | ||
| } | ||
| } | ||
| return; | ||
@@ -260,6 +246,3 @@ } | ||
| this.setOptions = (opts2) => { | ||
| Object.entries(opts2).forEach(([key, value]) => { | ||
| if (typeof value === "undefined") delete opts2[key]; | ||
| }); | ||
| this.options = { | ||
| const merged = { | ||
| debug: false, | ||
@@ -289,5 +272,9 @@ initialOffset: 0, | ||
| useAnimationFrameWithResizeObserver: false, | ||
| laneAssignmentMode: "estimate", | ||
| ...opts2 | ||
| laneAssignmentMode: "estimate" | ||
| }; | ||
| for (const key in opts2) { | ||
| const v = opts2[key]; | ||
| if (v !== void 0) merged[key] = v; | ||
| } | ||
| this.options = merged; | ||
| }; | ||
@@ -363,2 +350,6 @@ this.notify = (sync) => { | ||
| this.options.observeElementOffset(this, (offset, isScrolling) => { | ||
| if (this._intendedScrollOffset !== null && Math.abs(offset - this._intendedScrollOffset) < 1.5) { | ||
| offset = this._intendedScrollOffset; | ||
| } | ||
| this._intendedScrollOffset = null; | ||
| this.scrollAdjustments = 0; | ||
@@ -368,2 +359,3 @@ this.scrollDirection = isScrolling ? this.getScrollOffset() < offset ? "forward" : "backward" : null; | ||
| this.isScrolling = isScrolling; | ||
| this._flushIosDeferredIfReady(); | ||
| if (this.scrollState) { | ||
@@ -375,2 +367,43 @@ this.scheduleScrollReconcile(); | ||
| ); | ||
| if ("addEventListener" in this.scrollElement) { | ||
| const scrollEl = this.scrollElement; | ||
| 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) { | ||
| return; | ||
| } | ||
| this._iosJustTouchEnded = true; | ||
| this._iosTouchEndTimerId = this.targetWindow.setTimeout(() => { | ||
| this._iosJustTouchEnded = false; | ||
| this._iosTouchEndTimerId = null; | ||
| 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(), { | ||
@@ -382,2 +415,17 @@ adjustments: void 0, | ||
| }; | ||
| this._flushIosDeferredIfReady = () => { | ||
| if (this._iosDeferredAdjustment === 0) return; | ||
| if (this.isScrolling) return; | ||
| if (this._iosTouching) return; | ||
| if (this._iosJustTouchEnded) return; | ||
| const cur = this.getScrollOffset(); | ||
| const max = this.getMaxScrollOffset(); | ||
| if (cur < 0 || cur > max) return; | ||
| const delta = this._iosDeferredAdjustment; | ||
| this._iosDeferredAdjustment = 0; | ||
| this._scrollToOffset(cur, { | ||
| adjustments: this.scrollAdjustments += delta, | ||
| behavior: void 0 | ||
| }); | ||
| }; | ||
| this.rafId = null; | ||
@@ -443,3 +491,3 @@ this.getSize = () => { | ||
| this.prevLanes = lanes; | ||
| this.pendingMeasuredCacheIndexes = []; | ||
| this.pendingMin = null; | ||
| return { | ||
@@ -460,3 +508,3 @@ count, | ||
| this.getMeasurements = utils.memo( | ||
| () => [this.getMeasurementOptions(), this.itemSizeCache], | ||
| () => [this.getMeasurementOptions(), this.itemSizeCacheVersion], | ||
| ({ | ||
@@ -470,3 +518,4 @@ count, | ||
| laneAssignmentMode | ||
| }, itemSizeCache) => { | ||
| }, _itemSizeCacheVersion) => { | ||
| const itemSizeCache = this.itemSizeCache; | ||
| if (!enabled) { | ||
@@ -491,3 +540,3 @@ this.measurementsCache = []; | ||
| this.laneAssignments.clear(); | ||
| this.pendingMeasuredCacheIndexes = []; | ||
| this.pendingMin = null; | ||
| } | ||
@@ -500,7 +549,36 @@ if (this.measurementsCache.length === 0 && !this.lanesSettling) { | ||
| } | ||
| const min = this.lanesSettling ? 0 : this.pendingMeasuredCacheIndexes.length > 0 ? Math.min(...this.pendingMeasuredCacheIndexes) : 0; | ||
| this.pendingMeasuredCacheIndexes = []; | ||
| const min = this.lanesSettling ? 0 : this.pendingMin ?? 0; | ||
| this.pendingMin = null; | ||
| if (this.lanesSettling && this.measurementsCache.length === count) { | ||
| this.lanesSettling = false; | ||
| } | ||
| if (lanes === 1) { | ||
| const gap = this.options.gap; | ||
| 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; | ||
| if (min === 0) { | ||
| runningStart = paddingStart + scrollMargin; | ||
| } else { | ||
| 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 = lazyMeasurements.createLazyMeasurementsView(count, flat, getItemKey); | ||
| this.measurementsCache = view; | ||
| return view; | ||
| } | ||
| const measurements = this.measurementsCache.slice(0, min); | ||
@@ -568,3 +646,7 @@ const laneLastIndex = new Array(lanes).fill( | ||
| scrollOffset, | ||
| lanes | ||
| lanes, | ||
| // Pass the typed array so binary search + forward-walk can | ||
| // read start/end directly from Float64Array, skipping the | ||
| // Proxy traps that materialize a full VirtualItem per probe. | ||
| flat: lanes === 1 && this._flatMeasurements != null ? this._flatMeasurements : null | ||
| }) : null; | ||
@@ -665,18 +747,60 @@ }, | ||
| var _a; | ||
| const item = this.measurementsCache[index]; | ||
| if (!item) return; | ||
| const itemSize = this.itemSizeCache.get(item.key) ?? item.size; | ||
| if (index < 0 || index >= this.options.count) return; | ||
| let cachedSize; | ||
| let itemStart; | ||
| let 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) { | ||
| if (((_a = this.scrollState) == null ? void 0 : _a.behavior) !== "smooth" && (this.shouldAdjustScrollPositionOnItemSizeChange !== void 0 ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this) : item.start < this.getScrollOffset() + this.scrollAdjustments)) { | ||
| if (((_a = this.scrollState) == null ? void 0 : _a.behavior) !== "smooth" && (this.shouldAdjustScrollPositionOnItemSizeChange !== void 0 ? 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 scrollTop only when the resize is an above- | ||
| // viewport item AND we're not actively scrolling backward. | ||
| // Adjusting during backward scroll fights the user's scroll | ||
| // direction and produces the "items jump while scrolling up" | ||
| // jank reported across many issues. Users who want the old | ||
| // behavior can pass shouldAdjustScrollPositionOnItemSizeChange. | ||
| itemStart < this.getScrollOffset() + this.scrollAdjustments && this.scrollDirection !== "backward" | ||
| ))) { | ||
| if (process.env.NODE_ENV !== "production" && this.options.debug) { | ||
| console.info("correction", delta); | ||
| } | ||
| this._scrollToOffset(this.getScrollOffset(), { | ||
| adjustments: this.scrollAdjustments += delta, | ||
| behavior: void 0 | ||
| }); | ||
| if (isIOSWebKit() && (this.isScrolling || this._iosTouching || this._iosJustTouchEnded)) { | ||
| this._iosDeferredAdjustment += delta; | ||
| } else { | ||
| this._scrollToOffset(this.getScrollOffset(), { | ||
| adjustments: this.scrollAdjustments += delta, | ||
| behavior: void 0 | ||
| }); | ||
| } | ||
| } | ||
| this.pendingMeasuredCacheIndexes.push(item.index); | ||
| this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size)); | ||
| if (this.pendingMin === null || index < this.pendingMin) { | ||
| this.pendingMin = index; | ||
| } | ||
| this.itemSizeCache.set(key, size); | ||
| this.itemSizeCacheVersion++; | ||
| this.notify(false); | ||
@@ -706,10 +830,11 @@ } | ||
| } | ||
| return utils.notUndefined( | ||
| measurements[findNearestBinarySearch( | ||
| 0, | ||
| measurements.length - 1, | ||
| (index) => utils.notUndefined(measurements[index]).start, | ||
| offset | ||
| )] | ||
| const flat = this._flatMeasurements; | ||
| const useFlat = this.options.lanes === 1 && flat != null; | ||
| const idx = findNearestBinarySearch( | ||
| 0, | ||
| measurements.length - 1, | ||
| useFlat ? (i) => flat[i * 2] : (i) => utils.notUndefined(measurements[i]).start, | ||
| offset | ||
| ); | ||
| return utils.notUndefined(measurements[idx]); | ||
| }; | ||
@@ -821,3 +946,9 @@ this.getMaxScrollOffset = () => { | ||
| } else if (this.options.lanes === 1) { | ||
| end = ((_a = measurements[measurements.length - 1]) == null ? void 0 : _a.end) ?? 0; | ||
| const lastIdx = measurements.length - 1; | ||
| const flat = this._flatMeasurements; | ||
| if (flat != null) { | ||
| end = flat[lastIdx * 2] + flat[lastIdx * 2 + 1]; | ||
| } else { | ||
| end = ((_a = measurements[lastIdx]) == null ? void 0 : _a.end) ?? 0; | ||
| } | ||
| } else { | ||
@@ -840,2 +971,20 @@ const endByLane = Array(this.options.lanes).fill(null); | ||
| }; | ||
| this.takeSnapshot = () => { | ||
| const snapshot = []; | ||
| if (this.itemSizeCache.size === 0) return snapshot; | ||
| const m = this.getMeasurements(); | ||
| for (const item of m) { | ||
| if (item && this.itemSizeCache.has(item.key)) { | ||
| snapshot.push({ | ||
| index: item.index, | ||
| key: item.key, | ||
| start: item.start, | ||
| size: item.size, | ||
| end: item.end, | ||
| lane: item.lane | ||
| }); | ||
| } | ||
| } | ||
| return snapshot; | ||
| }; | ||
| this._scrollToOffset = (offset, { | ||
@@ -845,7 +994,10 @@ adjustments, | ||
| }) => { | ||
| this._intendedScrollOffset = offset + (adjustments ?? 0); | ||
| this.options.scrollToFn(offset, { behavior, adjustments }, this); | ||
| }; | ||
| this.measure = () => { | ||
| this.itemSizeCache = /* @__PURE__ */ new Map(); | ||
| this.laneAssignments = /* @__PURE__ */ new Map(); | ||
| this.pendingMin = null; | ||
| this.itemSizeCache.clear(); | ||
| this.laneAssignments.clear(); | ||
| this.itemSizeCacheVersion++; | ||
| this.notify(false); | ||
@@ -882,2 +1034,8 @@ }; | ||
| if (this.scrollState.stableFrames >= STABLE_FRAMES) { | ||
| if (this.getScrollOffset() !== targetOffset) { | ||
| this._scrollToOffset(targetOffset, { | ||
| adjustments: void 0, | ||
| behavior: "auto" | ||
| }); | ||
| } | ||
| this.scrollState = null; | ||
@@ -889,7 +1047,12 @@ return; | ||
| if (targetChanged) { | ||
| const viewport = this.getSize() || 600; | ||
| const distance = Math.abs(targetOffset - this.getScrollOffset()); | ||
| const keepSmooth = this.scrollState.behavior === "smooth" && distance > viewport; | ||
| this.scrollState.lastTargetOffset = targetOffset; | ||
| this.scrollState.behavior = "auto"; | ||
| if (!keepSmooth) { | ||
| this.scrollState.behavior = "auto"; | ||
| } | ||
| this._scrollToOffset(targetOffset, { | ||
| adjustments: void 0, | ||
| behavior: "auto" | ||
| behavior: keepSmooth ? "smooth" : "auto" | ||
| }); | ||
@@ -923,6 +1086,8 @@ } | ||
| scrollOffset, | ||
| lanes | ||
| lanes, | ||
| flat | ||
| }) { | ||
| const lastIndex = measurements.length - 1; | ||
| const getOffset = (index) => measurements[index].start; | ||
| const getStart = flat ? (index) => flat[index * 2] : (index) => measurements[index].start; | ||
| const getEnd = flat ? (index) => flat[index * 2] + flat[index * 2 + 1] : (index) => measurements[index].end; | ||
| if (measurements.length <= lanes) { | ||
@@ -934,11 +1099,6 @@ return { | ||
| } | ||
| let startIndex = findNearestBinarySearch( | ||
| 0, | ||
| lastIndex, | ||
| getOffset, | ||
| scrollOffset | ||
| ); | ||
| let startIndex = findNearestBinarySearch(0, lastIndex, getStart, scrollOffset); | ||
| let endIndex = startIndex; | ||
| if (lanes === 1) { | ||
| while (endIndex < lastIndex && measurements[endIndex].end < scrollOffset + outerSize) { | ||
| while (endIndex < lastIndex && getEnd(endIndex) < scrollOffset + outerSize) { | ||
| endIndex++; | ||
@@ -969,2 +1129,3 @@ } | ||
| exports.Virtualizer = Virtualizer; | ||
| exports._resetIOSDetectionForTests = _resetIOSDetectionForTests; | ||
| exports.defaultKeyExtractor = defaultKeyExtractor; | ||
@@ -971,0 +1132,0 @@ exports.defaultRangeExtractor = defaultRangeExtractor; |
+26
-5
@@ -1,2 +0,4 @@ | ||
| export * from './utils.cjs'; | ||
| export declare const _resetIOSDetectionForTests: () => void; | ||
| export { approxEqual, debounce, memo, notUndefined } from './utils.cjs'; | ||
| export type { NoInfer, PartialKeys } from './utils.cjs'; | ||
| type ScrollDirection = 'forward' | 'backward'; | ||
@@ -38,7 +40,7 @@ type ScrollAlignment = 'start' | 'center' | 'end' | 'auto'; | ||
| export declare const measureElement: <TItemElement extends Element>(element: TItemElement, entry: ResizeObserverEntry | undefined, instance: Virtualizer<any, TItemElement>) => number; | ||
| export declare const windowScroll: <T extends Window>(offset: number, { adjustments, behavior, }: { | ||
| export declare const windowScroll: <T extends Window>(offset: number, options: { | ||
| adjustments?: number; | ||
| behavior?: ScrollBehavior; | ||
| }, instance: Virtualizer<T, any>) => void; | ||
| export declare const elementScroll: <T extends Element>(offset: number, { adjustments, behavior, }: { | ||
| export declare const elementScroll: <T extends Element>(offset: number, options: { | ||
| adjustments?: number; | ||
@@ -91,5 +93,7 @@ behavior?: ScrollBehavior; | ||
| measurementsCache: Array<VirtualItem>; | ||
| private _flatMeasurements; | ||
| private itemSizeCache; | ||
| private itemSizeCacheVersion; | ||
| private laneAssignments; | ||
| private pendingMeasuredCacheIndexes; | ||
| private pendingMin; | ||
| private prevLanes; | ||
@@ -102,2 +106,7 @@ private lanesChangedFlag; | ||
| private scrollAdjustments; | ||
| private _iosDeferredAdjustment; | ||
| private _iosTouching; | ||
| private _iosJustTouchEnded; | ||
| private _iosTouchEndTimerId; | ||
| private _intendedScrollOffset; | ||
| shouldAdjustScrollPositionOnItemSizeChange: undefined | ((item: VirtualItem, delta: number, instance: Virtualizer<TScrollElement, TItemElement>) => boolean); | ||
@@ -118,2 +127,3 @@ elementsCache: Map<Key, TItemElement>; | ||
| _willUpdate: () => void; | ||
| private _flushIosDeferredIfReady; | ||
| private rafId; | ||
@@ -136,3 +146,3 @@ private scheduleScrollReconcile; | ||
| (): number[]; | ||
| updateDeps(newDeps: [(range: Range) => number[], number, number, number | null, number | null]): void; | ||
| updateDeps(newDeps: [(range: Range) => Array<number>, number, number, number | null, number | null]): void; | ||
| }; | ||
@@ -160,4 +170,15 @@ indexFromElement: (node: TItemElement) => number; | ||
| 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<VirtualItem>; | ||
| private _scrollToOffset; | ||
| measure: () => void; | ||
| } |
@@ -8,5 +8,6 @@ "use strict"; | ||
| function memoizedFunction() { | ||
| var _a, _b, _c; | ||
| let depTime; | ||
| if (opts.key && ((_a = opts.debug) == null ? void 0 : _a.call(opts))) depTime = Date.now(); | ||
| var _a; | ||
| const debugEnabled = process.env.NODE_ENV !== "production" && !!opts.key && !!((_a = opts.debug) == null ? void 0 : _a.call(opts)); | ||
| let depTime = 0; | ||
| if (debugEnabled) depTime = Date.now(); | ||
| const newDeps = getDeps(); | ||
@@ -18,6 +19,6 @@ const depsChanged = newDeps.length !== deps.length || newDeps.some((dep, index) => deps[index] !== dep); | ||
| deps = newDeps; | ||
| let resultTime; | ||
| if (opts.key && ((_b = opts.debug) == null ? void 0 : _b.call(opts))) resultTime = Date.now(); | ||
| let resultTime = 0; | ||
| if (debugEnabled) resultTime = Date.now(); | ||
| result = fn(...newDeps); | ||
| if (opts.key && ((_c = opts.debug) == null ? void 0 : _c.call(opts))) { | ||
| if (debugEnabled) { | ||
| const depEndTime = Math.round((Date.now() - depTime) * 100) / 100; | ||
@@ -24,0 +25,0 @@ const resultEndTime = Math.round((Date.now() - resultTime) * 100) / 100; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"utils.cjs","sources":["../../src/utils.ts"],"sourcesContent":["export type NoInfer<A extends any> = [A][A extends any ? 0 : never]\n\nexport type PartialKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>\n\nexport function memo<TDeps extends ReadonlyArray<any>, TResult>(\n getDeps: () => [...TDeps],\n fn: (...args: NoInfer<[...TDeps]>) => TResult,\n opts: {\n key: false | string\n debug?: () => boolean\n onChange?: (result: TResult) => void\n initialDeps?: TDeps\n skipInitialOnChange?: boolean\n },\n) {\n let deps = opts.initialDeps ?? []\n let result: TResult | undefined\n let isInitial = true\n\n function memoizedFunction(): TResult {\n let depTime: number\n if (opts.key && opts.debug?.()) depTime = Date.now()\n\n const newDeps = getDeps()\n\n const depsChanged =\n newDeps.length !== deps.length ||\n newDeps.some((dep: any, index: number) => deps[index] !== dep)\n\n if (!depsChanged) {\n return result!\n }\n\n deps = newDeps\n\n let resultTime: number\n if (opts.key && opts.debug?.()) resultTime = Date.now()\n\n result = fn(...newDeps)\n\n if (opts.key && opts.debug?.()) {\n const depEndTime = Math.round((Date.now() - depTime!) * 100) / 100\n const resultEndTime = Math.round((Date.now() - resultTime!) * 100) / 100\n const resultFpsPercentage = resultEndTime / 16\n\n const pad = (str: number | string, num: number) => {\n str = String(str)\n while (str.length < num) {\n str = ' ' + str\n }\n return str\n }\n\n console.info(\n `%c⏱ ${pad(resultEndTime, 5)} /${pad(depEndTime, 5)} ms`,\n `\n font-size: .6rem;\n font-weight: bold;\n color: hsl(${Math.max(\n 0,\n Math.min(120 - 120 * resultFpsPercentage, 120),\n )}deg 100% 31%);`,\n opts?.key,\n )\n }\n\n if (opts?.onChange && !(isInitial && opts.skipInitialOnChange)) {\n opts.onChange(result)\n }\n\n isInitial = false\n\n return result\n }\n\n // Attach updateDeps to the function itself\n memoizedFunction.updateDeps = (newDeps: [...TDeps]) => {\n deps = newDeps\n }\n\n return memoizedFunction\n}\n\nexport function notUndefined<T>(value: T | undefined, msg?: string): T {\n if (value === undefined) {\n throw new Error(`Unexpected undefined${msg ? `: ${msg}` : ''}`)\n } else {\n return value\n }\n}\n\nexport const approxEqual = (a: number, b: number) => Math.abs(a - b) < 1.01\n\nexport const debounce = (\n targetWindow: Window & typeof globalThis,\n fn: Function,\n ms: number,\n) => {\n let timeoutId: number\n return function (this: any, ...args: Array<any>) {\n targetWindow.clearTimeout(timeoutId)\n timeoutId = targetWindow.setTimeout(() => fn.apply(this, args), ms)\n }\n}\n"],"names":[],"mappings":";;AAIO,SAAS,KACd,SACA,IACA,MAOA;AACA,MAAI,OAAO,KAAK,eAAe,CAAA;AAC/B,MAAI;AACJ,MAAI,YAAY;AAEhB,WAAS,mBAA4B;;AACnC,QAAI;AACJ,QAAI,KAAK,SAAO,UAAK,UAAL,+BAAgB,WAAU,KAAK,IAAA;AAE/C,UAAM,UAAU,QAAA;AAEhB,UAAM,cACJ,QAAQ,WAAW,KAAK,UACxB,QAAQ,KAAK,CAAC,KAAU,UAAkB,KAAK,KAAK,MAAM,GAAG;AAE/D,QAAI,CAAC,aAAa;AAChB,aAAO;AAAA,IACT;AAEA,WAAO;AAEP,QAAI;AACJ,QAAI,KAAK,SAAO,UAAK,UAAL,+BAAgB,cAAa,KAAK,IAAA;AAElD,aAAS,GAAG,GAAG,OAAO;AAEtB,QAAI,KAAK,SAAO,UAAK,UAAL,gCAAgB;AAC9B,YAAM,aAAa,KAAK,OAAO,KAAK,QAAQ,WAAY,GAAG,IAAI;AAC/D,YAAM,gBAAgB,KAAK,OAAO,KAAK,QAAQ,cAAe,GAAG,IAAI;AACrE,YAAM,sBAAsB,gBAAgB;AAE5C,YAAM,MAAM,CAAC,KAAsB,QAAgB;AACjD,cAAM,OAAO,GAAG;AAChB,eAAO,IAAI,SAAS,KAAK;AACvB,gBAAM,MAAM;AAAA,QACd;AACA,eAAO;AAAA,MACT;AAEA,cAAQ;AAAA,QACN,OAAO,IAAI,eAAe,CAAC,CAAC,KAAK,IAAI,YAAY,CAAC,CAAC;AAAA,QACnD;AAAA;AAAA;AAAA,yBAGiB,KAAK;AAAA,UAChB;AAAA,UACA,KAAK,IAAI,MAAM,MAAM,qBAAqB,GAAG;AAAA,QAAA,CAC9C;AAAA,QACL,6BAAM;AAAA,MAAA;AAAA,IAEV;AAEA,SAAI,6BAAM,aAAY,EAAE,aAAa,KAAK,sBAAsB;AAC9D,WAAK,SAAS,MAAM;AAAA,IACtB;AAEA,gBAAY;AAEZ,WAAO;AAAA,EACT;AAGA,mBAAiB,aAAa,CAAC,YAAwB;AACrD,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,aAAgB,OAAsB,KAAiB;AACrE,MAAI,UAAU,QAAW;AACvB,UAAM,IAAI,MAAM,uBAAuB,MAAM,KAAK,GAAG,KAAK,EAAE,EAAE;AAAA,EAChE,OAAO;AACL,WAAO;AAAA,EACT;AACF;AAEO,MAAM,cAAc,CAAC,GAAW,MAAc,KAAK,IAAI,IAAI,CAAC,IAAI;AAEhE,MAAM,WAAW,CACtB,cACA,IACA,OACG;AACH,MAAI;AACJ,SAAO,YAAwB,MAAkB;AAC/C,iBAAa,aAAa,SAAS;AACnC,gBAAY,aAAa,WAAW,MAAM,GAAG,MAAM,MAAM,IAAI,GAAG,EAAE;AAAA,EACpE;AACF;;;;;"} | ||
| {"version":3,"file":"utils.cjs","sources":["../../src/utils.ts"],"sourcesContent":["export type NoInfer<A extends any> = [A][A extends any ? 0 : never]\n\nexport type PartialKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>\n\nexport function memo<TDeps extends ReadonlyArray<any>, TResult>(\n getDeps: () => [...TDeps],\n fn: (...args: NoInfer<[...TDeps]>) => TResult,\n opts: {\n key: false | string\n debug?: () => boolean\n onChange?: (result: TResult) => void\n initialDeps?: TDeps\n skipInitialOnChange?: boolean\n },\n) {\n let deps = opts.initialDeps ?? []\n let result: TResult | undefined\n let isInitial = true\n\n function memoizedFunction(): TResult {\n // Debug-only timing. In production builds, `process.env.NODE_ENV !==\n // 'production'` is constant-folded to `false` by downstream minifiers\n // (Terser/esbuild/swc with `define`), which DCEs the entire block.\n const debugEnabled =\n process.env.NODE_ENV !== 'production' && !!opts.key && !!opts.debug?.()\n let depTime = 0\n if (debugEnabled) depTime = Date.now()\n\n const newDeps = getDeps()\n\n const depsChanged =\n newDeps.length !== deps.length ||\n newDeps.some((dep: any, index: number) => deps[index] !== dep)\n\n if (!depsChanged) {\n return result!\n }\n\n deps = newDeps\n\n let resultTime = 0\n if (debugEnabled) resultTime = Date.now()\n\n result = fn(...newDeps)\n\n if (debugEnabled) {\n const depEndTime = Math.round((Date.now() - depTime) * 100) / 100\n const resultEndTime = Math.round((Date.now() - resultTime) * 100) / 100\n const resultFpsPercentage = resultEndTime / 16\n\n const pad = (str: number | string, num: number) => {\n str = String(str)\n while (str.length < num) {\n str = ' ' + str\n }\n return str\n }\n\n console.info(\n `%c⏱ ${pad(resultEndTime, 5)} /${pad(depEndTime, 5)} ms`,\n `\n font-size: .6rem;\n font-weight: bold;\n color: hsl(${Math.max(\n 0,\n Math.min(120 - 120 * resultFpsPercentage, 120),\n )}deg 100% 31%);`,\n opts?.key,\n )\n }\n\n if (opts?.onChange && !(isInitial && opts.skipInitialOnChange)) {\n opts.onChange(result)\n }\n\n isInitial = false\n\n return result\n }\n\n // Attach updateDeps to the function itself\n memoizedFunction.updateDeps = (newDeps: [...TDeps]) => {\n deps = newDeps\n }\n\n return memoizedFunction\n}\n\nexport function notUndefined<T>(value: T | undefined, msg?: string): T {\n if (value === undefined) {\n throw new Error(`Unexpected undefined${msg ? `: ${msg}` : ''}`)\n } else {\n return value\n }\n}\n\nexport const approxEqual = (a: number, b: number) => Math.abs(a - b) < 1.01\n\nexport const debounce = (\n targetWindow: Window & typeof globalThis,\n fn: Function,\n ms: number,\n) => {\n let timeoutId: number\n return function (this: any, ...args: Array<any>) {\n targetWindow.clearTimeout(timeoutId)\n timeoutId = targetWindow.setTimeout(() => fn.apply(this, args), ms)\n }\n}\n"],"names":[],"mappings":";;AAIO,SAAS,KACd,SACA,IACA,MAOA;AACA,MAAI,OAAO,KAAK,eAAe,CAAA;AAC/B,MAAI;AACJ,MAAI,YAAY;AAEhB,WAAS,mBAA4B;;AAInC,UAAM,eACJ,QAAQ,IAAI,aAAa,gBAAgB,CAAC,CAAC,KAAK,OAAO,CAAC,GAAC,UAAK,UAAL;AAC3D,QAAI,UAAU;AACd,QAAI,aAAc,WAAU,KAAK,IAAA;AAEjC,UAAM,UAAU,QAAA;AAEhB,UAAM,cACJ,QAAQ,WAAW,KAAK,UACxB,QAAQ,KAAK,CAAC,KAAU,UAAkB,KAAK,KAAK,MAAM,GAAG;AAE/D,QAAI,CAAC,aAAa;AAChB,aAAO;AAAA,IACT;AAEA,WAAO;AAEP,QAAI,aAAa;AACjB,QAAI,aAAc,cAAa,KAAK,IAAA;AAEpC,aAAS,GAAG,GAAG,OAAO;AAEtB,QAAI,cAAc;AAChB,YAAM,aAAa,KAAK,OAAO,KAAK,QAAQ,WAAW,GAAG,IAAI;AAC9D,YAAM,gBAAgB,KAAK,OAAO,KAAK,QAAQ,cAAc,GAAG,IAAI;AACpE,YAAM,sBAAsB,gBAAgB;AAE5C,YAAM,MAAM,CAAC,KAAsB,QAAgB;AACjD,cAAM,OAAO,GAAG;AAChB,eAAO,IAAI,SAAS,KAAK;AACvB,gBAAM,MAAM;AAAA,QACd;AACA,eAAO;AAAA,MACT;AAEA,cAAQ;AAAA,QACN,OAAO,IAAI,eAAe,CAAC,CAAC,KAAK,IAAI,YAAY,CAAC,CAAC;AAAA,QACnD;AAAA;AAAA;AAAA,yBAGiB,KAAK;AAAA,UAChB;AAAA,UACA,KAAK,IAAI,MAAM,MAAM,qBAAqB,GAAG;AAAA,QAAA,CAC9C;AAAA,QACL,6BAAM;AAAA,MAAA;AAAA,IAEV;AAEA,SAAI,6BAAM,aAAY,EAAE,aAAa,KAAK,sBAAsB;AAC9D,WAAK,SAAS,MAAM;AAAA,IACtB;AAEA,gBAAY;AAEZ,WAAO;AAAA,EACT;AAGA,mBAAiB,aAAa,CAAC,YAAwB;AACrD,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,aAAgB,OAAsB,KAAiB;AACrE,MAAI,UAAU,QAAW;AACvB,UAAM,IAAI,MAAM,uBAAuB,MAAM,KAAK,GAAG,KAAK,EAAE,EAAE;AAAA,EAChE,OAAO;AACL,WAAO;AAAA,EACT;AACF;AAEO,MAAM,cAAc,CAAC,GAAW,MAAc,KAAK,IAAI,IAAI,CAAC,IAAI;AAEhE,MAAM,WAAW,CACtB,cACA,IACA,OACG;AACH,MAAI;AACJ,SAAO,YAAwB,MAAkB;AAC/C,iBAAa,aAAa,SAAS;AACnC,gBAAY,aAAa,WAAW,MAAM,GAAG,MAAM,MAAM,IAAI,GAAG,EAAE;AAAA,EACpE;AACF;;;;;"} |
+26
-5
@@ -1,2 +0,4 @@ | ||
| export * from './utils.js'; | ||
| export declare const _resetIOSDetectionForTests: () => void; | ||
| export { approxEqual, debounce, memo, notUndefined } from './utils.js'; | ||
| export type { NoInfer, PartialKeys } from './utils.js'; | ||
| type ScrollDirection = 'forward' | 'backward'; | ||
@@ -38,7 +40,7 @@ type ScrollAlignment = 'start' | 'center' | 'end' | 'auto'; | ||
| export declare const measureElement: <TItemElement extends Element>(element: TItemElement, entry: ResizeObserverEntry | undefined, instance: Virtualizer<any, TItemElement>) => number; | ||
| export declare const windowScroll: <T extends Window>(offset: number, { adjustments, behavior, }: { | ||
| export declare const windowScroll: <T extends Window>(offset: number, options: { | ||
| adjustments?: number; | ||
| behavior?: ScrollBehavior; | ||
| }, instance: Virtualizer<T, any>) => void; | ||
| export declare const elementScroll: <T extends Element>(offset: number, { adjustments, behavior, }: { | ||
| export declare const elementScroll: <T extends Element>(offset: number, options: { | ||
| adjustments?: number; | ||
@@ -91,5 +93,7 @@ behavior?: ScrollBehavior; | ||
| measurementsCache: Array<VirtualItem>; | ||
| private _flatMeasurements; | ||
| private itemSizeCache; | ||
| private itemSizeCacheVersion; | ||
| private laneAssignments; | ||
| private pendingMeasuredCacheIndexes; | ||
| private pendingMin; | ||
| private prevLanes; | ||
@@ -102,2 +106,7 @@ private lanesChangedFlag; | ||
| private scrollAdjustments; | ||
| private _iosDeferredAdjustment; | ||
| private _iosTouching; | ||
| private _iosJustTouchEnded; | ||
| private _iosTouchEndTimerId; | ||
| private _intendedScrollOffset; | ||
| shouldAdjustScrollPositionOnItemSizeChange: undefined | ((item: VirtualItem, delta: number, instance: Virtualizer<TScrollElement, TItemElement>) => boolean); | ||
@@ -118,2 +127,3 @@ elementsCache: Map<Key, TItemElement>; | ||
| _willUpdate: () => void; | ||
| private _flushIosDeferredIfReady; | ||
| private rafId; | ||
@@ -136,3 +146,3 @@ private scheduleScrollReconcile; | ||
| (): number[]; | ||
| updateDeps(newDeps: [(range: Range) => number[], number, number, number | null, number | null]): void; | ||
| updateDeps(newDeps: [(range: Range) => Array<number>, number, number, number | null, number | null]): void; | ||
| }; | ||
@@ -160,4 +170,15 @@ indexFromElement: (node: TItemElement) => number; | ||
| 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<VirtualItem>; | ||
| private _scrollToOffset; | ||
| measure: () => void; | ||
| } |
+268
-107
@@ -1,2 +0,14 @@ | ||
| import { debounce, memo, notUndefined, approxEqual } from "./utils.js"; | ||
| import { createLazyMeasurementsView } from "./lazy-measurements.js"; | ||
| import { memo, notUndefined, approxEqual, debounce } from "./utils.js"; | ||
| let _isIOSResult; | ||
| const isIOSWebKit = () => { | ||
| if (_isIOSResult !== void 0) return _isIOSResult; | ||
| if (typeof navigator === "undefined") return _isIOSResult = false; | ||
| if (/iP(hone|od|ad)/.test(navigator.userAgent)) return _isIOSResult = true; | ||
| const mtp = navigator.maxTouchPoints; | ||
| return _isIOSResult = navigator.platform === "MacIntel" && mtp !== void 0 && mtp > 0; | ||
| }; | ||
| const _resetIOSDetectionForTests = () => { | ||
| _isIOSResult = void 0; | ||
| }; | ||
| const getRect = (element) => { | ||
@@ -10,5 +22,6 @@ const { offsetWidth, offsetHeight } = element; | ||
| const end = Math.min(range.endIndex + range.overscan, range.count - 1); | ||
| const arr = []; | ||
| for (let i = start; i <= end; i++) { | ||
| arr.push(i); | ||
| const len = end - start + 1; | ||
| const arr = new Array(len); | ||
| for (let i = 0; i < len; i++) { | ||
| arr[i] = start + i; | ||
| } | ||
@@ -72,3 +85,3 @@ return arr; | ||
| const supportsScrollend = typeof window == "undefined" ? true : "onscrollend" in window; | ||
| const observeElementOffset = (instance, cb) => { | ||
| const observeOffset = (instance, cb, readOffset) => { | ||
| const element = instance.scrollElement; | ||
@@ -82,50 +95,12 @@ if (!element) { | ||
| } | ||
| let offset = 0; | ||
| const fallback = instance.options.useScrollendEvent && supportsScrollend ? () => void 0 : debounce( | ||
| targetWindow, | ||
| () => { | ||
| cb(offset, false); | ||
| }, | ||
| instance.options.isScrollingResetDelay | ||
| ); | ||
| const createHandler = (isScrolling) => () => { | ||
| const { horizontal, isRtl } = instance.options; | ||
| offset = horizontal ? element["scrollLeft"] * (isRtl && -1 || 1) : element["scrollTop"]; | ||
| fallback(); | ||
| cb(offset, isScrolling); | ||
| }; | ||
| const handler = createHandler(true); | ||
| const endHandler = createHandler(false); | ||
| element.addEventListener("scroll", handler, addEventListenerOptions); | ||
| const registerScrollendEvent = instance.options.useScrollendEvent && supportsScrollend; | ||
| if (registerScrollendEvent) { | ||
| element.addEventListener("scrollend", endHandler, addEventListenerOptions); | ||
| } | ||
| return () => { | ||
| element.removeEventListener("scroll", handler); | ||
| if (registerScrollendEvent) { | ||
| element.removeEventListener("scrollend", endHandler); | ||
| } | ||
| }; | ||
| }; | ||
| const observeWindowOffset = (instance, cb) => { | ||
| const element = instance.scrollElement; | ||
| if (!element) { | ||
| return; | ||
| } | ||
| const targetWindow = instance.targetWindow; | ||
| if (!targetWindow) { | ||
| return; | ||
| } | ||
| let offset = 0; | ||
| const fallback = instance.options.useScrollendEvent && supportsScrollend ? () => void 0 : debounce( | ||
| const fallback = registerScrollendEvent ? null : debounce( | ||
| targetWindow, | ||
| () => { | ||
| cb(offset, false); | ||
| }, | ||
| () => cb(offset, false), | ||
| instance.options.isScrollingResetDelay | ||
| ); | ||
| const createHandler = (isScrolling) => () => { | ||
| offset = element[instance.options.horizontal ? "scrollX" : "scrollY"]; | ||
| fallback(); | ||
| offset = readOffset(element); | ||
| fallback == null ? void 0 : fallback(); | ||
| cb(offset, isScrolling); | ||
@@ -136,3 +111,2 @@ }; | ||
| element.addEventListener("scroll", handler, addEventListenerOptions); | ||
| const registerScrollendEvent = instance.options.useScrollendEvent && supportsScrollend; | ||
| if (registerScrollendEvent) { | ||
@@ -148,2 +122,11 @@ element.addEventListener("scrollend", endHandler, addEventListenerOptions); | ||
| }; | ||
| const observeElementOffset = (instance, cb) => observeOffset(instance, cb, (el) => { | ||
| const { horizontal, isRtl } = instance.options; | ||
| return horizontal ? el.scrollLeft * (isRtl && -1 || 1) : el.scrollTop; | ||
| }); | ||
| const observeWindowOffset = (instance, cb) => observeOffset( | ||
| instance, | ||
| cb, | ||
| (win) => instance.options.horizontal ? win.scrollX : win.scrollY | ||
| ); | ||
| const measureElement = (element, entry, instance) => { | ||
@@ -161,3 +144,3 @@ if (entry == null ? void 0 : entry.borderBoxSize) { | ||
| }; | ||
| const windowScroll = (offset, { | ||
| const scrollWithAdjustments = (offset, { | ||
| adjustments = 0, | ||
@@ -167,19 +150,9 @@ behavior | ||
| var _a, _b; | ||
| const toOffset = offset + adjustments; | ||
| (_b = (_a = instance.scrollElement) == null ? void 0 : _a.scrollTo) == null ? void 0 : _b.call(_a, { | ||
| [instance.options.horizontal ? "left" : "top"]: toOffset, | ||
| [instance.options.horizontal ? "left" : "top"]: offset + adjustments, | ||
| behavior | ||
| }); | ||
| }; | ||
| const elementScroll = (offset, { | ||
| adjustments = 0, | ||
| behavior | ||
| }, instance) => { | ||
| var _a, _b; | ||
| const toOffset = offset + adjustments; | ||
| (_b = (_a = instance.scrollElement) == null ? void 0 : _a.scrollTo) == null ? void 0 : _b.call(_a, { | ||
| [instance.options.horizontal ? "left" : "top"]: toOffset, | ||
| behavior | ||
| }); | ||
| }; | ||
| const windowScroll = scrollWithAdjustments; | ||
| const elementScroll = scrollWithAdjustments; | ||
| class Virtualizer { | ||
@@ -193,5 +166,7 @@ constructor(opts) { | ||
| this.measurementsCache = []; | ||
| this._flatMeasurements = null; | ||
| this.itemSizeCache = /* @__PURE__ */ new Map(); | ||
| this.itemSizeCacheVersion = 0; | ||
| this.laneAssignments = /* @__PURE__ */ new Map(); | ||
| this.pendingMeasuredCacheIndexes = []; | ||
| this.pendingMin = null; | ||
| this.prevLanes = void 0; | ||
@@ -204,2 +179,7 @@ this.lanesChangedFlag = false; | ||
| this.scrollAdjustments = 0; | ||
| this._iosDeferredAdjustment = 0; | ||
| this._iosTouching = false; | ||
| this._iosJustTouchEnded = false; | ||
| this._iosTouchEndTimerId = null; | ||
| this._intendedScrollOffset = null; | ||
| this.elementsCache = /* @__PURE__ */ new Map(); | ||
@@ -226,2 +206,8 @@ this.now = () => { | ||
| this.observer.unobserve(node); | ||
| for (const [cacheKey, cachedNode] of this.elementsCache) { | ||
| if (cachedNode === node) { | ||
| this.elementsCache.delete(cacheKey); | ||
| break; | ||
| } | ||
| } | ||
| return; | ||
@@ -258,6 +244,3 @@ } | ||
| this.setOptions = (opts2) => { | ||
| Object.entries(opts2).forEach(([key, value]) => { | ||
| if (typeof value === "undefined") delete opts2[key]; | ||
| }); | ||
| this.options = { | ||
| const merged = { | ||
| debug: false, | ||
@@ -287,5 +270,9 @@ initialOffset: 0, | ||
| useAnimationFrameWithResizeObserver: false, | ||
| laneAssignmentMode: "estimate", | ||
| ...opts2 | ||
| laneAssignmentMode: "estimate" | ||
| }; | ||
| for (const key in opts2) { | ||
| const v = opts2[key]; | ||
| if (v !== void 0) merged[key] = v; | ||
| } | ||
| this.options = merged; | ||
| }; | ||
@@ -361,2 +348,6 @@ this.notify = (sync) => { | ||
| this.options.observeElementOffset(this, (offset, isScrolling) => { | ||
| if (this._intendedScrollOffset !== null && Math.abs(offset - this._intendedScrollOffset) < 1.5) { | ||
| offset = this._intendedScrollOffset; | ||
| } | ||
| this._intendedScrollOffset = null; | ||
| this.scrollAdjustments = 0; | ||
@@ -366,2 +357,3 @@ this.scrollDirection = isScrolling ? this.getScrollOffset() < offset ? "forward" : "backward" : null; | ||
| this.isScrolling = isScrolling; | ||
| this._flushIosDeferredIfReady(); | ||
| if (this.scrollState) { | ||
@@ -373,2 +365,43 @@ this.scheduleScrollReconcile(); | ||
| ); | ||
| if ("addEventListener" in this.scrollElement) { | ||
| const scrollEl = this.scrollElement; | ||
| 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) { | ||
| return; | ||
| } | ||
| this._iosJustTouchEnded = true; | ||
| this._iosTouchEndTimerId = this.targetWindow.setTimeout(() => { | ||
| this._iosJustTouchEnded = false; | ||
| this._iosTouchEndTimerId = null; | ||
| 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(), { | ||
@@ -380,2 +413,17 @@ adjustments: void 0, | ||
| }; | ||
| this._flushIosDeferredIfReady = () => { | ||
| if (this._iosDeferredAdjustment === 0) return; | ||
| if (this.isScrolling) return; | ||
| if (this._iosTouching) return; | ||
| if (this._iosJustTouchEnded) return; | ||
| const cur = this.getScrollOffset(); | ||
| const max = this.getMaxScrollOffset(); | ||
| if (cur < 0 || cur > max) return; | ||
| const delta = this._iosDeferredAdjustment; | ||
| this._iosDeferredAdjustment = 0; | ||
| this._scrollToOffset(cur, { | ||
| adjustments: this.scrollAdjustments += delta, | ||
| behavior: void 0 | ||
| }); | ||
| }; | ||
| this.rafId = null; | ||
@@ -441,3 +489,3 @@ this.getSize = () => { | ||
| this.prevLanes = lanes; | ||
| this.pendingMeasuredCacheIndexes = []; | ||
| this.pendingMin = null; | ||
| return { | ||
@@ -458,3 +506,3 @@ count, | ||
| this.getMeasurements = memo( | ||
| () => [this.getMeasurementOptions(), this.itemSizeCache], | ||
| () => [this.getMeasurementOptions(), this.itemSizeCacheVersion], | ||
| ({ | ||
@@ -468,3 +516,4 @@ count, | ||
| laneAssignmentMode | ||
| }, itemSizeCache) => { | ||
| }, _itemSizeCacheVersion) => { | ||
| const itemSizeCache = this.itemSizeCache; | ||
| if (!enabled) { | ||
@@ -489,3 +538,3 @@ this.measurementsCache = []; | ||
| this.laneAssignments.clear(); | ||
| this.pendingMeasuredCacheIndexes = []; | ||
| this.pendingMin = null; | ||
| } | ||
@@ -498,7 +547,36 @@ if (this.measurementsCache.length === 0 && !this.lanesSettling) { | ||
| } | ||
| const min = this.lanesSettling ? 0 : this.pendingMeasuredCacheIndexes.length > 0 ? Math.min(...this.pendingMeasuredCacheIndexes) : 0; | ||
| this.pendingMeasuredCacheIndexes = []; | ||
| const min = this.lanesSettling ? 0 : this.pendingMin ?? 0; | ||
| this.pendingMin = null; | ||
| if (this.lanesSettling && this.measurementsCache.length === count) { | ||
| this.lanesSettling = false; | ||
| } | ||
| if (lanes === 1) { | ||
| const gap = this.options.gap; | ||
| 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; | ||
| if (min === 0) { | ||
| runningStart = paddingStart + scrollMargin; | ||
| } else { | ||
| 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); | ||
@@ -566,3 +644,7 @@ const laneLastIndex = new Array(lanes).fill( | ||
| scrollOffset, | ||
| lanes | ||
| lanes, | ||
| // Pass the typed array so binary search + forward-walk can | ||
| // read start/end directly from Float64Array, skipping the | ||
| // Proxy traps that materialize a full VirtualItem per probe. | ||
| flat: lanes === 1 && this._flatMeasurements != null ? this._flatMeasurements : null | ||
| }) : null; | ||
@@ -663,18 +745,60 @@ }, | ||
| var _a; | ||
| const item = this.measurementsCache[index]; | ||
| if (!item) return; | ||
| const itemSize = this.itemSizeCache.get(item.key) ?? item.size; | ||
| if (index < 0 || index >= this.options.count) return; | ||
| let cachedSize; | ||
| let itemStart; | ||
| let 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) { | ||
| if (((_a = this.scrollState) == null ? void 0 : _a.behavior) !== "smooth" && (this.shouldAdjustScrollPositionOnItemSizeChange !== void 0 ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this) : item.start < this.getScrollOffset() + this.scrollAdjustments)) { | ||
| if (((_a = this.scrollState) == null ? void 0 : _a.behavior) !== "smooth" && (this.shouldAdjustScrollPositionOnItemSizeChange !== void 0 ? 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 scrollTop only when the resize is an above- | ||
| // viewport item AND we're not actively scrolling backward. | ||
| // Adjusting during backward scroll fights the user's scroll | ||
| // direction and produces the "items jump while scrolling up" | ||
| // jank reported across many issues. Users who want the old | ||
| // behavior can pass shouldAdjustScrollPositionOnItemSizeChange. | ||
| itemStart < this.getScrollOffset() + this.scrollAdjustments && this.scrollDirection !== "backward" | ||
| ))) { | ||
| if (process.env.NODE_ENV !== "production" && this.options.debug) { | ||
| console.info("correction", delta); | ||
| } | ||
| this._scrollToOffset(this.getScrollOffset(), { | ||
| adjustments: this.scrollAdjustments += delta, | ||
| behavior: void 0 | ||
| }); | ||
| if (isIOSWebKit() && (this.isScrolling || this._iosTouching || this._iosJustTouchEnded)) { | ||
| this._iosDeferredAdjustment += delta; | ||
| } else { | ||
| this._scrollToOffset(this.getScrollOffset(), { | ||
| adjustments: this.scrollAdjustments += delta, | ||
| behavior: void 0 | ||
| }); | ||
| } | ||
| } | ||
| this.pendingMeasuredCacheIndexes.push(item.index); | ||
| this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size)); | ||
| if (this.pendingMin === null || index < this.pendingMin) { | ||
| this.pendingMin = index; | ||
| } | ||
| this.itemSizeCache.set(key, size); | ||
| this.itemSizeCacheVersion++; | ||
| this.notify(false); | ||
@@ -704,10 +828,11 @@ } | ||
| } | ||
| return notUndefined( | ||
| measurements[findNearestBinarySearch( | ||
| 0, | ||
| measurements.length - 1, | ||
| (index) => notUndefined(measurements[index]).start, | ||
| offset | ||
| )] | ||
| const flat = this._flatMeasurements; | ||
| const useFlat = this.options.lanes === 1 && flat != null; | ||
| const idx = findNearestBinarySearch( | ||
| 0, | ||
| measurements.length - 1, | ||
| useFlat ? (i) => flat[i * 2] : (i) => notUndefined(measurements[i]).start, | ||
| offset | ||
| ); | ||
| return notUndefined(measurements[idx]); | ||
| }; | ||
@@ -819,3 +944,9 @@ this.getMaxScrollOffset = () => { | ||
| } else if (this.options.lanes === 1) { | ||
| end = ((_a = measurements[measurements.length - 1]) == null ? void 0 : _a.end) ?? 0; | ||
| const lastIdx = measurements.length - 1; | ||
| const flat = this._flatMeasurements; | ||
| if (flat != null) { | ||
| end = flat[lastIdx * 2] + flat[lastIdx * 2 + 1]; | ||
| } else { | ||
| end = ((_a = measurements[lastIdx]) == null ? void 0 : _a.end) ?? 0; | ||
| } | ||
| } else { | ||
@@ -838,2 +969,20 @@ const endByLane = Array(this.options.lanes).fill(null); | ||
| }; | ||
| this.takeSnapshot = () => { | ||
| const snapshot = []; | ||
| if (this.itemSizeCache.size === 0) return snapshot; | ||
| const m = this.getMeasurements(); | ||
| for (const item of m) { | ||
| if (item && this.itemSizeCache.has(item.key)) { | ||
| snapshot.push({ | ||
| index: item.index, | ||
| key: item.key, | ||
| start: item.start, | ||
| size: item.size, | ||
| end: item.end, | ||
| lane: item.lane | ||
| }); | ||
| } | ||
| } | ||
| return snapshot; | ||
| }; | ||
| this._scrollToOffset = (offset, { | ||
@@ -843,7 +992,10 @@ adjustments, | ||
| }) => { | ||
| this._intendedScrollOffset = offset + (adjustments ?? 0); | ||
| this.options.scrollToFn(offset, { behavior, adjustments }, this); | ||
| }; | ||
| this.measure = () => { | ||
| this.itemSizeCache = /* @__PURE__ */ new Map(); | ||
| this.laneAssignments = /* @__PURE__ */ new Map(); | ||
| this.pendingMin = null; | ||
| this.itemSizeCache.clear(); | ||
| this.laneAssignments.clear(); | ||
| this.itemSizeCacheVersion++; | ||
| this.notify(false); | ||
@@ -880,2 +1032,8 @@ }; | ||
| if (this.scrollState.stableFrames >= STABLE_FRAMES) { | ||
| if (this.getScrollOffset() !== targetOffset) { | ||
| this._scrollToOffset(targetOffset, { | ||
| adjustments: void 0, | ||
| behavior: "auto" | ||
| }); | ||
| } | ||
| this.scrollState = null; | ||
@@ -887,7 +1045,12 @@ return; | ||
| if (targetChanged) { | ||
| const viewport = this.getSize() || 600; | ||
| const distance = Math.abs(targetOffset - this.getScrollOffset()); | ||
| const keepSmooth = this.scrollState.behavior === "smooth" && distance > viewport; | ||
| this.scrollState.lastTargetOffset = targetOffset; | ||
| this.scrollState.behavior = "auto"; | ||
| if (!keepSmooth) { | ||
| this.scrollState.behavior = "auto"; | ||
| } | ||
| this._scrollToOffset(targetOffset, { | ||
| adjustments: void 0, | ||
| behavior: "auto" | ||
| behavior: keepSmooth ? "smooth" : "auto" | ||
| }); | ||
@@ -921,6 +1084,8 @@ } | ||
| scrollOffset, | ||
| lanes | ||
| lanes, | ||
| flat | ||
| }) { | ||
| const lastIndex = measurements.length - 1; | ||
| const getOffset = (index) => measurements[index].start; | ||
| const getStart = flat ? (index) => flat[index * 2] : (index) => measurements[index].start; | ||
| const getEnd = flat ? (index) => flat[index * 2] + flat[index * 2 + 1] : (index) => measurements[index].end; | ||
| if (measurements.length <= lanes) { | ||
@@ -932,11 +1097,6 @@ return { | ||
| } | ||
| let startIndex = findNearestBinarySearch( | ||
| 0, | ||
| lastIndex, | ||
| getOffset, | ||
| scrollOffset | ||
| ); | ||
| let startIndex = findNearestBinarySearch(0, lastIndex, getStart, scrollOffset); | ||
| let endIndex = startIndex; | ||
| if (lanes === 1) { | ||
| while (endIndex < lastIndex && measurements[endIndex].end < scrollOffset + outerSize) { | ||
| while (endIndex < lastIndex && getEnd(endIndex) < scrollOffset + outerSize) { | ||
| endIndex++; | ||
@@ -964,2 +1124,3 @@ } | ||
| Virtualizer, | ||
| _resetIOSDetectionForTests, | ||
| approxEqual, | ||
@@ -966,0 +1127,0 @@ debounce, |
@@ -6,5 +6,6 @@ function memo(getDeps, fn, opts) { | ||
| function memoizedFunction() { | ||
| var _a, _b, _c; | ||
| let depTime; | ||
| if (opts.key && ((_a = opts.debug) == null ? void 0 : _a.call(opts))) depTime = Date.now(); | ||
| var _a; | ||
| const debugEnabled = process.env.NODE_ENV !== "production" && !!opts.key && !!((_a = opts.debug) == null ? void 0 : _a.call(opts)); | ||
| let depTime = 0; | ||
| if (debugEnabled) depTime = Date.now(); | ||
| const newDeps = getDeps(); | ||
@@ -16,6 +17,6 @@ const depsChanged = newDeps.length !== deps.length || newDeps.some((dep, index) => deps[index] !== dep); | ||
| deps = newDeps; | ||
| let resultTime; | ||
| if (opts.key && ((_b = opts.debug) == null ? void 0 : _b.call(opts))) resultTime = Date.now(); | ||
| let resultTime = 0; | ||
| if (debugEnabled) resultTime = Date.now(); | ||
| result = fn(...newDeps); | ||
| if (opts.key && ((_c = opts.debug) == null ? void 0 : _c.call(opts))) { | ||
| if (debugEnabled) { | ||
| const depEndTime = Math.round((Date.now() - depTime) * 100) / 100; | ||
@@ -22,0 +23,0 @@ const resultEndTime = Math.round((Date.now() - resultTime) * 100) / 100; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"utils.js","sources":["../../src/utils.ts"],"sourcesContent":["export type NoInfer<A extends any> = [A][A extends any ? 0 : never]\n\nexport type PartialKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>\n\nexport function memo<TDeps extends ReadonlyArray<any>, TResult>(\n getDeps: () => [...TDeps],\n fn: (...args: NoInfer<[...TDeps]>) => TResult,\n opts: {\n key: false | string\n debug?: () => boolean\n onChange?: (result: TResult) => void\n initialDeps?: TDeps\n skipInitialOnChange?: boolean\n },\n) {\n let deps = opts.initialDeps ?? []\n let result: TResult | undefined\n let isInitial = true\n\n function memoizedFunction(): TResult {\n let depTime: number\n if (opts.key && opts.debug?.()) depTime = Date.now()\n\n const newDeps = getDeps()\n\n const depsChanged =\n newDeps.length !== deps.length ||\n newDeps.some((dep: any, index: number) => deps[index] !== dep)\n\n if (!depsChanged) {\n return result!\n }\n\n deps = newDeps\n\n let resultTime: number\n if (opts.key && opts.debug?.()) resultTime = Date.now()\n\n result = fn(...newDeps)\n\n if (opts.key && opts.debug?.()) {\n const depEndTime = Math.round((Date.now() - depTime!) * 100) / 100\n const resultEndTime = Math.round((Date.now() - resultTime!) * 100) / 100\n const resultFpsPercentage = resultEndTime / 16\n\n const pad = (str: number | string, num: number) => {\n str = String(str)\n while (str.length < num) {\n str = ' ' + str\n }\n return str\n }\n\n console.info(\n `%c⏱ ${pad(resultEndTime, 5)} /${pad(depEndTime, 5)} ms`,\n `\n font-size: .6rem;\n font-weight: bold;\n color: hsl(${Math.max(\n 0,\n Math.min(120 - 120 * resultFpsPercentage, 120),\n )}deg 100% 31%);`,\n opts?.key,\n )\n }\n\n if (opts?.onChange && !(isInitial && opts.skipInitialOnChange)) {\n opts.onChange(result)\n }\n\n isInitial = false\n\n return result\n }\n\n // Attach updateDeps to the function itself\n memoizedFunction.updateDeps = (newDeps: [...TDeps]) => {\n deps = newDeps\n }\n\n return memoizedFunction\n}\n\nexport function notUndefined<T>(value: T | undefined, msg?: string): T {\n if (value === undefined) {\n throw new Error(`Unexpected undefined${msg ? `: ${msg}` : ''}`)\n } else {\n return value\n }\n}\n\nexport const approxEqual = (a: number, b: number) => Math.abs(a - b) < 1.01\n\nexport const debounce = (\n targetWindow: Window & typeof globalThis,\n fn: Function,\n ms: number,\n) => {\n let timeoutId: number\n return function (this: any, ...args: Array<any>) {\n targetWindow.clearTimeout(timeoutId)\n timeoutId = targetWindow.setTimeout(() => fn.apply(this, args), ms)\n }\n}\n"],"names":[],"mappings":"AAIO,SAAS,KACd,SACA,IACA,MAOA;AACA,MAAI,OAAO,KAAK,eAAe,CAAA;AAC/B,MAAI;AACJ,MAAI,YAAY;AAEhB,WAAS,mBAA4B;AAfhC;AAgBH,QAAI;AACJ,QAAI,KAAK,SAAO,UAAK,UAAL,+BAAgB,WAAU,KAAK,IAAA;AAE/C,UAAM,UAAU,QAAA;AAEhB,UAAM,cACJ,QAAQ,WAAW,KAAK,UACxB,QAAQ,KAAK,CAAC,KAAU,UAAkB,KAAK,KAAK,MAAM,GAAG;AAE/D,QAAI,CAAC,aAAa;AAChB,aAAO;AAAA,IACT;AAEA,WAAO;AAEP,QAAI;AACJ,QAAI,KAAK,SAAO,UAAK,UAAL,+BAAgB,cAAa,KAAK,IAAA;AAElD,aAAS,GAAG,GAAG,OAAO;AAEtB,QAAI,KAAK,SAAO,UAAK,UAAL,gCAAgB;AAC9B,YAAM,aAAa,KAAK,OAAO,KAAK,QAAQ,WAAY,GAAG,IAAI;AAC/D,YAAM,gBAAgB,KAAK,OAAO,KAAK,QAAQ,cAAe,GAAG,IAAI;AACrE,YAAM,sBAAsB,gBAAgB;AAE5C,YAAM,MAAM,CAAC,KAAsB,QAAgB;AACjD,cAAM,OAAO,GAAG;AAChB,eAAO,IAAI,SAAS,KAAK;AACvB,gBAAM,MAAM;AAAA,QACd;AACA,eAAO;AAAA,MACT;AAEA,cAAQ;AAAA,QACN,OAAO,IAAI,eAAe,CAAC,CAAC,KAAK,IAAI,YAAY,CAAC,CAAC;AAAA,QACnD;AAAA;AAAA;AAAA,yBAGiB,KAAK;AAAA,UAChB;AAAA,UACA,KAAK,IAAI,MAAM,MAAM,qBAAqB,GAAG;AAAA,QAAA,CAC9C;AAAA,QACL,6BAAM;AAAA,MAAA;AAAA,IAEV;AAEA,SAAI,6BAAM,aAAY,EAAE,aAAa,KAAK,sBAAsB;AAC9D,WAAK,SAAS,MAAM;AAAA,IACtB;AAEA,gBAAY;AAEZ,WAAO;AAAA,EACT;AAGA,mBAAiB,aAAa,CAAC,YAAwB;AACrD,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,aAAgB,OAAsB,KAAiB;AACrE,MAAI,UAAU,QAAW;AACvB,UAAM,IAAI,MAAM,uBAAuB,MAAM,KAAK,GAAG,KAAK,EAAE,EAAE;AAAA,EAChE,OAAO;AACL,WAAO;AAAA,EACT;AACF;AAEO,MAAM,cAAc,CAAC,GAAW,MAAc,KAAK,IAAI,IAAI,CAAC,IAAI;AAEhE,MAAM,WAAW,CACtB,cACA,IACA,OACG;AACH,MAAI;AACJ,SAAO,YAAwB,MAAkB;AAC/C,iBAAa,aAAa,SAAS;AACnC,gBAAY,aAAa,WAAW,MAAM,GAAG,MAAM,MAAM,IAAI,GAAG,EAAE;AAAA,EACpE;AACF;"} | ||
| {"version":3,"file":"utils.js","sources":["../../src/utils.ts"],"sourcesContent":["export type NoInfer<A extends any> = [A][A extends any ? 0 : never]\n\nexport type PartialKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>\n\nexport function memo<TDeps extends ReadonlyArray<any>, TResult>(\n getDeps: () => [...TDeps],\n fn: (...args: NoInfer<[...TDeps]>) => TResult,\n opts: {\n key: false | string\n debug?: () => boolean\n onChange?: (result: TResult) => void\n initialDeps?: TDeps\n skipInitialOnChange?: boolean\n },\n) {\n let deps = opts.initialDeps ?? []\n let result: TResult | undefined\n let isInitial = true\n\n function memoizedFunction(): TResult {\n // Debug-only timing. In production builds, `process.env.NODE_ENV !==\n // 'production'` is constant-folded to `false` by downstream minifiers\n // (Terser/esbuild/swc with `define`), which DCEs the entire block.\n const debugEnabled =\n process.env.NODE_ENV !== 'production' && !!opts.key && !!opts.debug?.()\n let depTime = 0\n if (debugEnabled) depTime = Date.now()\n\n const newDeps = getDeps()\n\n const depsChanged =\n newDeps.length !== deps.length ||\n newDeps.some((dep: any, index: number) => deps[index] !== dep)\n\n if (!depsChanged) {\n return result!\n }\n\n deps = newDeps\n\n let resultTime = 0\n if (debugEnabled) resultTime = Date.now()\n\n result = fn(...newDeps)\n\n if (debugEnabled) {\n const depEndTime = Math.round((Date.now() - depTime) * 100) / 100\n const resultEndTime = Math.round((Date.now() - resultTime) * 100) / 100\n const resultFpsPercentage = resultEndTime / 16\n\n const pad = (str: number | string, num: number) => {\n str = String(str)\n while (str.length < num) {\n str = ' ' + str\n }\n return str\n }\n\n console.info(\n `%c⏱ ${pad(resultEndTime, 5)} /${pad(depEndTime, 5)} ms`,\n `\n font-size: .6rem;\n font-weight: bold;\n color: hsl(${Math.max(\n 0,\n Math.min(120 - 120 * resultFpsPercentage, 120),\n )}deg 100% 31%);`,\n opts?.key,\n )\n }\n\n if (opts?.onChange && !(isInitial && opts.skipInitialOnChange)) {\n opts.onChange(result)\n }\n\n isInitial = false\n\n return result\n }\n\n // Attach updateDeps to the function itself\n memoizedFunction.updateDeps = (newDeps: [...TDeps]) => {\n deps = newDeps\n }\n\n return memoizedFunction\n}\n\nexport function notUndefined<T>(value: T | undefined, msg?: string): T {\n if (value === undefined) {\n throw new Error(`Unexpected undefined${msg ? `: ${msg}` : ''}`)\n } else {\n return value\n }\n}\n\nexport const approxEqual = (a: number, b: number) => Math.abs(a - b) < 1.01\n\nexport const debounce = (\n targetWindow: Window & typeof globalThis,\n fn: Function,\n ms: number,\n) => {\n let timeoutId: number\n return function (this: any, ...args: Array<any>) {\n targetWindow.clearTimeout(timeoutId)\n timeoutId = targetWindow.setTimeout(() => fn.apply(this, args), ms)\n }\n}\n"],"names":[],"mappings":"AAIO,SAAS,KACd,SACA,IACA,MAOA;AACA,MAAI,OAAO,KAAK,eAAe,CAAA;AAC/B,MAAI;AACJ,MAAI,YAAY;AAEhB,WAAS,mBAA4B;AAfhC;AAmBH,UAAM,eACJ,QAAQ,IAAI,aAAa,gBAAgB,CAAC,CAAC,KAAK,OAAO,CAAC,GAAC,UAAK,UAAL;AAC3D,QAAI,UAAU;AACd,QAAI,aAAc,WAAU,KAAK,IAAA;AAEjC,UAAM,UAAU,QAAA;AAEhB,UAAM,cACJ,QAAQ,WAAW,KAAK,UACxB,QAAQ,KAAK,CAAC,KAAU,UAAkB,KAAK,KAAK,MAAM,GAAG;AAE/D,QAAI,CAAC,aAAa;AAChB,aAAO;AAAA,IACT;AAEA,WAAO;AAEP,QAAI,aAAa;AACjB,QAAI,aAAc,cAAa,KAAK,IAAA;AAEpC,aAAS,GAAG,GAAG,OAAO;AAEtB,QAAI,cAAc;AAChB,YAAM,aAAa,KAAK,OAAO,KAAK,QAAQ,WAAW,GAAG,IAAI;AAC9D,YAAM,gBAAgB,KAAK,OAAO,KAAK,QAAQ,cAAc,GAAG,IAAI;AACpE,YAAM,sBAAsB,gBAAgB;AAE5C,YAAM,MAAM,CAAC,KAAsB,QAAgB;AACjD,cAAM,OAAO,GAAG;AAChB,eAAO,IAAI,SAAS,KAAK;AACvB,gBAAM,MAAM;AAAA,QACd;AACA,eAAO;AAAA,MACT;AAEA,cAAQ;AAAA,QACN,OAAO,IAAI,eAAe,CAAC,CAAC,KAAK,IAAI,YAAY,CAAC,CAAC;AAAA,QACnD;AAAA;AAAA;AAAA,yBAGiB,KAAK;AAAA,UAChB;AAAA,UACA,KAAK,IAAI,MAAM,MAAM,qBAAqB,GAAG;AAAA,QAAA,CAC9C;AAAA,QACL,6BAAM;AAAA,MAAA;AAAA,IAEV;AAEA,SAAI,6BAAM,aAAY,EAAE,aAAa,KAAK,sBAAsB;AAC9D,WAAK,SAAS,MAAM;AAAA,IACtB;AAEA,gBAAY;AAEZ,WAAO;AAAA,EACT;AAGA,mBAAiB,aAAa,CAAC,YAAwB;AACrD,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,aAAgB,OAAsB,KAAiB;AACrE,MAAI,UAAU,QAAW;AACvB,UAAM,IAAI,MAAM,uBAAuB,MAAM,KAAK,GAAG,KAAK,EAAE,EAAE;AAAA,EAChE,OAAO;AACL,WAAO;AAAA,EACT;AACF;AAEO,MAAM,cAAc,CAAC,GAAW,MAAc,KAAK,IAAI,IAAI,CAAC,IAAI;AAEhE,MAAM,WAAW,CACtB,cACA,IACA,OACG;AACH,MAAI;AACJ,SAAO,YAAwB,MAAkB;AAC/C,iBAAa,aAAa,SAAS;AACnC,gBAAY,aAAa,WAAW,MAAM,GAAG,MAAM,MAAM,IAAI,GAAG,EAAE;AAAA,EACpE;AACF;"} |
+1
-1
| { | ||
| "name": "@tanstack/virtual-core", | ||
| "version": "3.14.0", | ||
| "version": "3.15.0", | ||
| "description": "Headless UI for virtualizing scrollable elements in TS/JS + Frameworks", | ||
@@ -5,0 +5,0 @@ "author": "Tanner Linsley", |
+452
-138
@@ -0,5 +1,28 @@ | ||
| import { createLazyMeasurementsView } from './lazy-measurements' | ||
| import { approxEqual, debounce, memo, notUndefined } from './utils' | ||
| export * 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' | ||
| // | ||
@@ -57,9 +80,8 @@ | ||
| const end = Math.min(range.endIndex + range.overscan, range.count - 1) | ||
| const len = end - start + 1 | ||
| const arr = [] | ||
| for (let i = start; i <= end; i++) { | ||
| arr.push(i) | ||
| const arr = new Array<number>(len) | ||
| for (let i = 0; i < len; i++) { | ||
| arr[i] = start + i | ||
| } | ||
| return arr | ||
@@ -147,5 +169,9 @@ } | ||
| export const observeElementOffset = <T extends Element>( | ||
| // 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 = <T extends Element | Window>( | ||
| instance: Virtualizer<T, any>, | ||
| cb: ObserveOffsetCallBack, | ||
| readOffset: (target: T) => number, | ||
| ) => { | ||
@@ -161,20 +187,17 @@ const element = instance.scrollElement | ||
| let offset = 0 | ||
| const fallback = | ||
| const registerScrollendEvent = | ||
| instance.options.useScrollendEvent && supportsScrollend | ||
| ? () => undefined | ||
| : debounce( | ||
| targetWindow, | ||
| () => { | ||
| cb(offset, false) | ||
| }, | ||
| instance.options.isScrollingResetDelay, | ||
| ) | ||
| let offset = 0 | ||
| const fallback = registerScrollendEvent | ||
| ? null | ||
| : debounce( | ||
| targetWindow, | ||
| () => cb(offset, false), | ||
| instance.options.isScrollingResetDelay, | ||
| ) | ||
| const createHandler = (isScrolling: boolean) => () => { | ||
| const { horizontal, isRtl } = instance.options | ||
| offset = horizontal | ||
| ? element['scrollLeft'] * ((isRtl && -1) || 1) | ||
| : element['scrollTop'] | ||
| fallback() | ||
| offset = readOffset(element) | ||
| fallback?.() | ||
| cb(offset, isScrolling) | ||
@@ -186,4 +209,2 @@ } | ||
| element.addEventListener('scroll', handler, addEventListenerOptions) | ||
| const registerScrollendEvent = | ||
| instance.options.useScrollendEvent && supportsScrollend | ||
| if (registerScrollendEvent) { | ||
@@ -200,49 +221,19 @@ element.addEventListener('scrollend', endHandler, addEventListenerOptions) | ||
| export const observeElementOffset = <T extends Element>( | ||
| instance: Virtualizer<T, any>, | ||
| 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<Window, any>, | ||
| cb: ObserveOffsetCallBack, | ||
| ) => { | ||
| const element = instance.scrollElement | ||
| if (!element) { | ||
| return | ||
| } | ||
| const targetWindow = instance.targetWindow | ||
| if (!targetWindow) { | ||
| return | ||
| } | ||
| ) => | ||
| observeOffset(instance, cb, (win) => | ||
| instance.options.horizontal ? win.scrollX : win.scrollY, | ||
| ) | ||
| let offset = 0 | ||
| const fallback = | ||
| instance.options.useScrollendEvent && supportsScrollend | ||
| ? () => undefined | ||
| : debounce( | ||
| targetWindow, | ||
| () => { | ||
| cb(offset, false) | ||
| }, | ||
| instance.options.isScrollingResetDelay, | ||
| ) | ||
| const createHandler = (isScrolling: boolean) => () => { | ||
| offset = element[instance.options.horizontal ? 'scrollX' : 'scrollY'] | ||
| fallback() | ||
| cb(offset, isScrolling) | ||
| } | ||
| const handler = createHandler(true) | ||
| const endHandler = createHandler(false) | ||
| element.addEventListener('scroll', handler, addEventListenerOptions) | ||
| const registerScrollendEvent = | ||
| instance.options.useScrollendEvent && supportsScrollend | ||
| if (registerScrollendEvent) { | ||
| element.addEventListener('scrollend', endHandler, addEventListenerOptions) | ||
| } | ||
| return () => { | ||
| element.removeEventListener('scroll', handler) | ||
| if (registerScrollendEvent) { | ||
| element.removeEventListener('scrollend', endHandler) | ||
| } | ||
| } | ||
| } | ||
| export const measureElement = <TItemElement extends Element>( | ||
@@ -268,3 +259,3 @@ element: TItemElement, | ||
| export const windowScroll = <T extends Window>( | ||
| const scrollWithAdjustments = ( | ||
| offset: number, | ||
@@ -275,8 +266,6 @@ { | ||
| }: { adjustments?: number; behavior?: ScrollBehavior }, | ||
| instance: Virtualizer<T, any>, | ||
| instance: Virtualizer<any, any>, | ||
| ) => { | ||
| const toOffset = offset + adjustments | ||
| instance.scrollElement?.scrollTo?.({ | ||
| [instance.options.horizontal ? 'left' : 'top']: toOffset, | ||
| [instance.options.horizontal ? 'left' : 'top']: offset + adjustments, | ||
| behavior, | ||
@@ -286,17 +275,13 @@ }) | ||
| export const elementScroll = <T extends Element>( | ||
| export const windowScroll: <T extends Window>( | ||
| offset: number, | ||
| { | ||
| adjustments = 0, | ||
| behavior, | ||
| }: { adjustments?: number; behavior?: ScrollBehavior }, | ||
| options: { adjustments?: number; behavior?: ScrollBehavior }, | ||
| instance: Virtualizer<T, any>, | ||
| ) => { | ||
| const toOffset = offset + adjustments | ||
| ) => void = scrollWithAdjustments | ||
| instance.scrollElement?.scrollTo?.({ | ||
| [instance.options.horizontal ? 'left' : 'top']: toOffset, | ||
| behavior, | ||
| }) | ||
| } | ||
| export const elementScroll: <T extends Element>( | ||
| offset: number, | ||
| options: { adjustments?: number; behavior?: ScrollBehavior }, | ||
| instance: Virtualizer<T, any>, | ||
| ) => void = scrollWithAdjustments | ||
@@ -389,5 +374,10 @@ type LaneAssignmentMode = 'estimate' | 'measured' | ||
| measurementsCache: Array<VirtualItem> = [] | ||
| // 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 | ||
| private itemSizeCache = new Map<Key, number>() | ||
| private itemSizeCacheVersion = 0 | ||
| private laneAssignments = new Map<number, number>() // index → lane cache | ||
| private pendingMeasuredCacheIndexes: Array<number> = [] | ||
| // Earliest index dirtied since last getMeasurements() rebuild, or null. | ||
| private pendingMin: number | null = null | ||
| private prevLanes: number | undefined = undefined | ||
@@ -400,2 +390,24 @@ private lanesChangedFlag = false | ||
| private scrollAdjustments = 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 | ||
| shouldAdjustScrollPositionOnItemSizeChange: | ||
@@ -430,2 +442,16 @@ | undefined | ||
| 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 | ||
@@ -465,7 +491,5 @@ } | ||
| setOptions = (opts: VirtualizerOptions<TScrollElement, TItemElement>) => { | ||
| Object.entries(opts).forEach(([key, value]) => { | ||
| if (typeof value === 'undefined') delete (opts as any)[key] | ||
| }) | ||
| this.options = { | ||
| // Skip `{...defaults, ...opts}` because explicit `undefined` values in | ||
| // opts would override defaults with `undefined`. | ||
| const merged = { | ||
| debug: false, | ||
@@ -495,4 +519,10 @@ initialOffset: 0, | ||
| laneAssignmentMode: 'estimate', | ||
| ...opts, | ||
| } as unknown as Required<VirtualizerOptions<TScrollElement, TItemElement>> | ||
| for (const key in opts) { | ||
| const v = (opts as any)[key] | ||
| if (v !== undefined) (merged as any)[key] = v | ||
| } | ||
| this.options = merged | ||
| } | ||
@@ -581,2 +611,17 @@ | ||
| this.options.observeElementOffset(this, (offset, isScrolling) => { | ||
| // 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 | ||
@@ -591,2 +636,7 @@ this.scrollDirection = 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) { | ||
@@ -599,2 +649,52 @@ this.scheduleScrollReconcile() | ||
| // 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(), { | ||
@@ -607,2 +707,30 @@ adjustments: undefined, | ||
| // 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 | ||
@@ -651,2 +779,14 @@ private scheduleScrollReconcile() { | ||
| 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 | ||
@@ -659,10 +799,22 @@ return | ||
| 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 | ||
| // Switch to 'auto' behavior once measurements cause target to change | ||
| // We want to jump directly to the correct position, not smoothly animate to it | ||
| this.scrollState.behavior = 'auto' | ||
| if (!keepSmooth) { | ||
| this.scrollState.behavior = 'auto' | ||
| } | ||
| this._scrollToOffset(targetOffset, { | ||
| adjustments: undefined, | ||
| behavior: 'auto', | ||
| behavior: keepSmooth ? 'smooth' : 'auto', | ||
| }) | ||
@@ -773,3 +925,3 @@ } | ||
| this.prevLanes = lanes | ||
| this.pendingMeasuredCacheIndexes = [] | ||
| this.pendingMin = null | ||
@@ -792,3 +944,3 @@ return { | ||
| private getMeasurements = memo( | ||
| () => [this.getMeasurementOptions(), this.itemSizeCache], | ||
| () => [this.getMeasurementOptions(), this.itemSizeCacheVersion], | ||
| ( | ||
@@ -804,4 +956,5 @@ { | ||
| }, | ||
| itemSizeCache, | ||
| _itemSizeCacheVersion, | ||
| ) => { | ||
| const itemSizeCache = this.itemSizeCache | ||
| if (!enabled) { | ||
@@ -830,4 +983,4 @@ this.measurementsCache = [] | ||
| this.laneAssignments.clear() // Clear lane cache for new lane count | ||
| // Clear pending indexes to force min = 0 | ||
| this.pendingMeasuredCacheIndexes = [] | ||
| // Force min = 0 on the rebuild | ||
| this.pendingMin = null | ||
| } | ||
@@ -844,9 +997,5 @@ | ||
| // ✅ During lanes settling, ignore pendingMeasuredCacheIndexes to prevent repositioning | ||
| const min = this.lanesSettling | ||
| ? 0 | ||
| : this.pendingMeasuredCacheIndexes.length > 0 | ||
| ? Math.min(...this.pendingMeasuredCacheIndexes) | ||
| : 0 | ||
| this.pendingMeasuredCacheIndexes = [] | ||
| // During lanes settling, ignore pendingMin to prevent repositioning | ||
| const min = this.lanesSettling ? 0 : (this.pendingMin ?? 0) | ||
| this.pendingMin = null | ||
@@ -858,2 +1007,49 @@ // ✅ End settling period when cache is fully built | ||
| // ─── 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) { | ||
| const gap = this.options.gap | ||
| // 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 | ||
| } 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) | ||
@@ -960,2 +1156,9 @@ | ||
| lanes, | ||
| // Pass the typed array so binary search + forward-walk can | ||
| // read start/end directly from Float64Array, skipping the | ||
| // Proxy traps that materialize a full VirtualItem per probe. | ||
| flat: | ||
| lanes === 1 && this._flatMeasurements != null | ||
| ? this._flatMeasurements | ||
| : null, | ||
| }) | ||
@@ -1085,6 +1288,24 @@ : null) | ||
| resizeItem = (index: number, size: number) => { | ||
| const item = this.measurementsCache[index] | ||
| if (!item) return | ||
| if (index < 0 || index >= this.options.count) return | ||
| const itemSize = this.itemSizeCache.get(item.key) ?? item.size | ||
| // 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 | ||
@@ -1096,4 +1317,24 @@ | ||
| (this.shouldAdjustScrollPositionOnItemSizeChange !== undefined | ||
| ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this) | ||
| : item.start < this.getScrollOffset() + this.scrollAdjustments) | ||
| ? 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 scrollTop only when the resize is an above- | ||
| // viewport item AND we're not actively scrolling backward. | ||
| // Adjusting during backward scroll fights the user's scroll | ||
| // direction and produces the "items jump while scrolling up" | ||
| // jank reported across many issues. Users who want the old | ||
| // behavior can pass shouldAdjustScrollPositionOnItemSizeChange. | ||
| itemStart < this.getScrollOffset() + this.scrollAdjustments && | ||
| this.scrollDirection !== 'backward') | ||
| ) { | ||
@@ -1103,10 +1344,24 @@ if (process.env.NODE_ENV !== 'production' && this.options.debug) { | ||
| } | ||
| this._scrollToOffset(this.getScrollOffset(), { | ||
| adjustments: (this.scrollAdjustments += delta), | ||
| behavior: undefined, | ||
| }) | ||
| // On iOS WebKit, writing scrollTop while a finger is on screen or | ||
| // momentum-scroll is running cancels the in-flight scroll. Defer | ||
| // the adjustment until iOS is fully settled — flushed by either | ||
| // the scroll callback or the touchend grace-timer. | ||
| if ( | ||
| isIOSWebKit() && | ||
| (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) | ||
| ) { | ||
| this._iosDeferredAdjustment += delta | ||
| } else { | ||
| this._scrollToOffset(this.getScrollOffset(), { | ||
| adjustments: (this.scrollAdjustments += delta), | ||
| behavior: undefined, | ||
| }) | ||
| } | ||
| } | ||
| this.pendingMeasuredCacheIndexes.push(item.index) | ||
| this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size)) | ||
| if (this.pendingMin === null || index < this.pendingMin) { | ||
| this.pendingMin = index | ||
| } | ||
| this.itemSizeCache.set(key, size) | ||
| this.itemSizeCacheVersion++ | ||
@@ -1142,12 +1397,16 @@ this.notify(false) | ||
| } | ||
| return notUndefined( | ||
| measurements[ | ||
| findNearestBinarySearch( | ||
| 0, | ||
| measurements.length - 1, | ||
| (index: number) => notUndefined(measurements[index]).start, | ||
| offset, | ||
| ) | ||
| ], | ||
| // 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]) | ||
| } | ||
@@ -1317,3 +1576,12 @@ | ||
| } else if (this.options.lanes === 1) { | ||
| end = measurements[measurements.length - 1]?.end ?? 0 | ||
| // 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 { | ||
@@ -1340,2 +1608,35 @@ const endByLane = Array<number | null>(this.options.lanes).fill(null) | ||
| /** | ||
| * 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<VirtualItem> => { | ||
| const snapshot: Array<VirtualItem> = [] | ||
| 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 = ( | ||
@@ -1351,2 +1652,5 @@ offset: number, | ||
| ) => { | ||
| // 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) | ||
@@ -1356,4 +1660,9 @@ } | ||
| measure = () => { | ||
| this.itemSizeCache = new Map() | ||
| this.laneAssignments = new Map() // Clear lane cache for full re-layout | ||
| // 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) | ||
@@ -1394,2 +1703,3 @@ } | ||
| lanes, | ||
| flat, | ||
| }: { | ||
@@ -1400,5 +1710,14 @@ measurements: Array<VirtualItem> | ||
| lanes: number | ||
| flat: Float64Array | null | ||
| }) { | ||
| const lastIndex = measurements.length - 1 | ||
| const getOffset = (index: number) => measurements[index]!.start | ||
| // When the lanes===1 fast-path is active, read start/end directly from the | ||
| // flat Float64Array instead of going through the lazy-view Proxy. Cuts | ||
| // ~17 Proxy.get traps per scroll for the binary search alone. | ||
| const getStart = flat | ||
| ? (index: number) => flat[index * 2]! | ||
| : (index: number) => measurements[index]!.start | ||
| const getEnd = flat | ||
| ? (index: number) => flat[index * 2]! + flat[index * 2 + 1]! | ||
| : (index: number) => measurements[index]!.end | ||
@@ -1413,8 +1732,3 @@ // handle case when item count is less than or equal to lanes | ||
| let startIndex = findNearestBinarySearch( | ||
| 0, | ||
| lastIndex, | ||
| getOffset, | ||
| scrollOffset, | ||
| ) | ||
| let startIndex = findNearestBinarySearch(0, lastIndex, getStart, scrollOffset) | ||
| let endIndex = startIndex | ||
@@ -1425,3 +1739,3 @@ | ||
| endIndex < lastIndex && | ||
| measurements[endIndex]!.end < scrollOffset + outerSize | ||
| getEnd(endIndex) < scrollOffset + outerSize | ||
| ) { | ||
@@ -1428,0 +1742,0 @@ endIndex++ |
+12
-7
@@ -21,4 +21,9 @@ export type NoInfer<A extends any> = [A][A extends any ? 0 : never] | ||
| function memoizedFunction(): TResult { | ||
| let depTime: number | ||
| if (opts.key && opts.debug?.()) depTime = Date.now() | ||
| // 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() | ||
@@ -37,10 +42,10 @@ const newDeps = getDeps() | ||
| let resultTime: number | ||
| if (opts.key && opts.debug?.()) resultTime = Date.now() | ||
| let resultTime = 0 | ||
| if (debugEnabled) resultTime = Date.now() | ||
| result = fn(...newDeps) | ||
| if (opts.key && opts.debug?.()) { | ||
| const depEndTime = Math.round((Date.now() - depTime!) * 100) / 100 | ||
| const resultEndTime = Math.round((Date.now() - resultTime!) * 100) / 100 | ||
| if (debugEnabled) { | ||
| const depEndTime = Math.round((Date.now() - depTime) * 100) / 100 | ||
| const resultEndTime = Math.round((Date.now() - resultTime) * 100) / 100 | ||
| const resultFpsPercentage = resultEndTime / 16 | ||
@@ -47,0 +52,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
336756
31.62%23
43.75%4288
21.78%29
38.1%