Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@tanstack/virtual-core

Package Overview
Dependencies
Maintainers
5
Versions
120
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@tanstack/virtual-core - npm Package Compare versions

Comparing version
3.14.0
to
3.15.0
+34
dist/cjs/lazy-measurements.cjs
"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;

@@ -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;;;;;"}

@@ -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;
}

@@ -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;"}
{
"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++

@@ -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