@tanstack/virtual-core
Version:
Headless UI for virtualizing scrollable elements in TS/JS + Frameworks
1,285 lines (1,284 loc) • 45.2 kB
JavaScript
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) => {
const { offsetWidth, offsetHeight } = element;
return { width: offsetWidth, height: offsetHeight };
};
const defaultKeyExtractor = (index) => index;
const defaultRangeExtractor = (range) => {
const start = Math.max(range.startIndex - range.overscan, 0);
const end = Math.min(range.endIndex + range.overscan, range.count - 1);
const len = end - start + 1;
const arr = new Array(len);
for (let i = 0; i < len; i++) {
arr[i] = start + i;
}
return arr;
};
const observeElementRect = (instance, cb) => {
const element = instance.scrollElement;
if (!element) {
return;
}
const targetWindow = instance.targetWindow;
if (!targetWindow) {
return;
}
const handler = (rect) => {
const { width, height } = rect;
cb({ width: Math.round(width), height: Math.round(height) });
};
handler(getRect(element));
if (!targetWindow.ResizeObserver) {
return () => {
};
}
const observer = new targetWindow.ResizeObserver((entries) => {
const run = () => {
const entry = entries[0];
if (entry == null ? void 0 : entry.borderBoxSize) {
const box = entry.borderBoxSize[0];
if (box) {
handler({ width: box.inlineSize, height: box.blockSize });
return;
}
}
handler(getRect(element));
};
instance.options.useAnimationFrameWithResizeObserver ? requestAnimationFrame(run) : run();
});
observer.observe(element, { box: "border-box" });
return () => {
observer.unobserve(element);
};
};
const addEventListenerOptions = {
passive: true
};
const observeWindowRect = (instance, cb) => {
const element = instance.scrollElement;
if (!element) {
return;
}
const handler = () => {
cb({ width: element.innerWidth, height: element.innerHeight });
};
handler();
element.addEventListener("resize", handler, addEventListenerOptions);
return () => {
element.removeEventListener("resize", handler);
};
};
const supportsScrollend = typeof window == "undefined" ? true : "onscrollend" in window;
const observeOffset = (instance, cb, readOffset) => {
const element = instance.scrollElement;
if (!element) {
return;
}
const targetWindow = instance.targetWindow;
if (!targetWindow) {
return;
}
const registerScrollendEvent = instance.options.useScrollendEvent && supportsScrollend;
let offset = 0;
const fallback = registerScrollendEvent ? null : debounce(
targetWindow,
() => cb(offset, false),
instance.options.isScrollingResetDelay
);
const createHandler = (isScrolling) => () => {
offset = readOffset(element);
fallback == null ? void 0 : fallback();
cb(offset, isScrolling);
};
const handler = createHandler(true);
const endHandler = createHandler(false);
element.addEventListener("scroll", handler, addEventListenerOptions);
if (registerScrollendEvent) {
element.addEventListener("scrollend", endHandler, addEventListenerOptions);
}
return () => {
element.removeEventListener("scroll", handler);
if (registerScrollendEvent) {
element.removeEventListener("scrollend", endHandler);
}
};
};
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) => {
if (instance.options.useCachedMeasurements) {
const index = instance.indexFromElement(element);
const key = instance.options.getItemKey(index);
return instance.itemSizeCache.get(key) ?? instance.options.estimateSize(index);
}
if (entry == null ? void 0 : entry.borderBoxSize) {
const box = entry.borderBoxSize[0];
if (box) {
const size = Math.round(
box[instance.options.horizontal ? "inlineSize" : "blockSize"]
);
return size;
}
}
if (!entry) {
const index = instance.indexFromElement(element);
const key = instance.options.getItemKey(index);
const cachedSize = instance.itemSizeCache.get(key);
if (cachedSize !== void 0) {
return cachedSize;
}
}
return element[instance.options.horizontal ? "offsetWidth" : "offsetHeight"];
};
const scrollWithAdjustments = (offset, {
adjustments = 0,
behavior
}, instance) => {
var _a, _b;
(_b = (_a = instance.scrollElement) == null ? void 0 : _a.scrollTo) == null ? void 0 : _b.call(_a, {
[instance.options.horizontal ? "left" : "top"]: offset + adjustments,
behavior
});
};
const windowScroll = scrollWithAdjustments;
const elementScroll = scrollWithAdjustments;
class Virtualizer {
constructor(opts) {
this.unsubs = [];
this.scrollElement = null;
this.targetWindow = null;
this.isScrolling = false;
this.scrollState = null;
this.measurementsCache = [];
this._flatMeasurements = null;
this.itemSizeCache = /* @__PURE__ */ new Map();
this.itemSizeCacheVersion = 0;
this.laneAssignments = /* @__PURE__ */ new Map();
this.pendingMin = null;
this.prevLanes = void 0;
this.lanesChangedFlag = false;
this.lanesSettling = false;
this.pendingScrollAnchor = null;
this.scrollRect = null;
this.scrollOffset = null;
this.scrollDirection = null;
this.scrollAdjustments = 0;
this._iosDeferredAdjustment = 0;
this._iosTouching = false;
this._iosJustTouchEnded = false;
this._iosTouchEndTimerId = null;
this._intendedScrollOffset = null;
this.elementsCache = /* @__PURE__ */ new Map();
this.now = () => {
var _a, _b, _c;
return ((_c = (_b = (_a = this.targetWindow) == null ? void 0 : _a.performance) == null ? void 0 : _b.now) == null ? void 0 : _c.call(_b)) ?? Date.now();
};
this.observer = /* @__PURE__ */ (() => {
let _ro = null;
const get = () => {
if (_ro) {
return _ro;
}
if (!this.targetWindow || !this.targetWindow.ResizeObserver) {
return null;
}
return _ro = new this.targetWindow.ResizeObserver((entries) => {
entries.forEach((entry) => {
const run = () => {
const node = entry.target;
const index = this.indexFromElement(node);
if (!node.isConnected) {
this.observer.unobserve(node);
for (const [cacheKey, cachedNode] of this.elementsCache) {
if (cachedNode === node) {
this.elementsCache.delete(cacheKey);
break;
}
}
return;
}
if (this.shouldMeasureDuringScroll(index)) {
this.resizeItem(
index,
this.options.measureElement(node, entry, this)
);
}
};
this.options.useAnimationFrameWithResizeObserver ? requestAnimationFrame(run) : run();
});
});
};
return {
disconnect: () => {
var _a;
(_a = get()) == null ? void 0 : _a.disconnect();
_ro = null;
},
observe: (target) => {
var _a;
return (_a = get()) == null ? void 0 : _a.observe(target, { box: "border-box" });
},
unobserve: (target) => {
var _a;
return (_a = get()) == null ? void 0 : _a.unobserve(target);
}
};
})();
this.range = null;
this.setOptions = (opts2) => {
var _a, _b;
const merged = {
debug: false,
initialOffset: 0,
overscan: 1,
paddingStart: 0,
paddingEnd: 0,
scrollPaddingStart: 0,
scrollPaddingEnd: 0,
horizontal: false,
getItemKey: defaultKeyExtractor,
rangeExtractor: defaultRangeExtractor,
onChange: () => {
},
measureElement,
initialRect: { width: 0, height: 0 },
scrollMargin: 0,
gap: 0,
indexAttribute: "data-index",
initialMeasurementsCache: [],
lanes: 1,
anchorTo: "start",
followOnAppend: false,
scrollEndThreshold: 1,
isScrollingResetDelay: 150,
enabled: true,
isRtl: false,
useScrollendEvent: false,
useAnimationFrameWithResizeObserver: false,
laneAssignmentMode: "estimate",
useCachedMeasurements: false
};
for (const key in opts2) {
const v = opts2[key];
if (v !== void 0) merged[key] = v;
}
const prevOptions = this.options;
let anchor = null;
let followOnAppend = null;
let edgeKeysChanged = false;
if (prevOptions !== void 0 && prevOptions.enabled && merged.enabled && merged.anchorTo === "end" && this.scrollElement !== null) {
const prevCount = prevOptions.count;
const nextCount = merged.count;
const measurements = this.getMeasurements();
const prevFirstKey = prevCount > 0 ? ((_a = measurements[0]) == null ? void 0 : _a.key) ?? prevOptions.getItemKey(0) : null;
const prevLastKey = prevCount > 0 ? ((_b = measurements[prevCount - 1]) == null ? void 0 : _b.key) ?? prevOptions.getItemKey(prevCount - 1) : null;
const didCountChange = nextCount !== prevCount;
const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey);
if (didEdgeKeysChange) {
edgeKeysChanged = true;
const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null;
if (item) {
anchor = [item.key, this.getScrollOffset() - item.start];
}
const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null;
if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) {
followOnAppend = behavior;
}
}
}
this.options = merged;
if (edgeKeysChanged) {
this.pendingMin = 0;
this.itemSizeCacheVersion++;
}
let anchorResolved = false;
let anchorDelta = 0;
if (anchor && this.scrollOffset !== null) {
const [anchorKey, anchorOffset] = anchor;
const newMeasurements = this.getMeasurements();
const { count, getItemKey } = this.options;
let idx = 0;
while (idx < count && getItemKey(idx) !== anchorKey) {
idx++;
}
if (idx < count) {
const anchorItem = newMeasurements[idx];
if (anchorItem) {
const newOffset = Math.max(0, anchorItem.start + anchorOffset);
if (newOffset !== this.scrollOffset) {
anchorDelta = newOffset - this.scrollOffset;
this.scrollOffset = newOffset;
anchorResolved = true;
}
}
}
}
if (anchorResolved || followOnAppend) {
this.pendingScrollAnchor = [
anchorResolved ? anchor[0] : null,
anchorResolved ? anchor[1] : 0,
followOnAppend,
anchorDelta
];
}
};
this.notify = (sync) => {
var _a, _b;
(_b = (_a = this.options).onChange) == null ? void 0 : _b.call(_a, this, sync);
};
this.maybeNotify = memo(
() => {
this.calculateRange();
return [
this.isScrolling,
this.range ? this.range.startIndex : null,
this.range ? this.range.endIndex : null
];
},
(isScrolling) => {
this.notify(isScrolling);
},
{
key: process.env.NODE_ENV !== "production" && "maybeNotify",
debug: () => this.options.debug,
initialDeps: [
this.isScrolling,
this.range ? this.range.startIndex : null,
this.range ? this.range.endIndex : null
]
}
);
this.cleanup = () => {
this.unsubs.filter(Boolean).forEach((d) => d());
this.unsubs = [];
this.observer.disconnect();
if (this.rafId != null && this.targetWindow) {
this.targetWindow.cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.scrollState = null;
this._iosDeferredAdjustment = 0;
this._iosTouching = false;
this._iosJustTouchEnded = false;
this.scrollElement = null;
this.targetWindow = null;
};
this._didMount = () => {
return () => {
this.cleanup();
};
};
this._willUpdate = () => {
var _a;
const scrollElement = this.options.enabled ? this.options.getScrollElement() : null;
if (this.scrollElement !== scrollElement) {
this.cleanup();
if (!scrollElement) {
this.maybeNotify();
return;
}
this.scrollElement = scrollElement;
if (this.scrollElement && "ownerDocument" in this.scrollElement) {
this.targetWindow = this.scrollElement.ownerDocument.defaultView;
} else {
this.targetWindow = ((_a = this.scrollElement) == null ? void 0 : _a.window) ?? null;
}
this.elementsCache.forEach((cached) => {
this.observer.observe(cached);
});
this.unsubs.push(
this.options.observeElementRect(this, (rect) => {
this.scrollRect = rect;
this.maybeNotify();
})
);
this.unsubs.push(
this.options.observeElementOffset(this, (offset, isScrolling) => {
if (isScrolling && this._intendedScrollOffset === null && offset === this.scrollOffset) {
return;
}
if (this._intendedScrollOffset !== null && Math.abs(offset - this._intendedScrollOffset) < 1.5) {
offset = this._intendedScrollOffset;
}
this._intendedScrollOffset = null;
this.scrollAdjustments = 0;
const prevOffset = this.getScrollOffset();
this.scrollDirection = isScrolling ? prevOffset === offset ? this.scrollDirection : prevOffset < offset ? "forward" : "backward" : null;
this.scrollOffset = offset;
this.isScrolling = isScrolling;
this._flushIosDeferredIfReady();
if (this.scrollState) {
this.scheduleScrollReconcile();
}
this.maybeNotify();
})
);
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(), {
adjustments: void 0,
behavior: void 0
});
}
const anchor = this.pendingScrollAnchor;
this.pendingScrollAnchor = null;
if (anchor && this.scrollElement && this.options.enabled) {
const [key, _offset, followOnAppend, anchorDelta] = anchor;
if (key !== null && !followOnAppend) {
if (isIOSWebKit() && (this.isScrolling || this._iosTouching || this._iosJustTouchEnded)) {
if (anchorDelta !== 0) {
this._iosDeferredAdjustment += anchorDelta;
}
} else {
this._scrollToOffset(this.getScrollOffset(), {
adjustments: void 0,
behavior: void 0
});
}
}
if (followOnAppend) {
this.scrollToEnd({ behavior: followOnAppend });
}
}
};
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;
if (this._iosDeferredAdjustment < 0 && cur >= max - 1) {
this._iosDeferredAdjustment = 0;
return;
}
const delta = this._iosDeferredAdjustment;
this._iosDeferredAdjustment = 0;
this._scrollToOffset(cur, {
adjustments: this.scrollAdjustments += delta,
behavior: void 0
});
};
this.rafId = null;
this.getSize = () => {
if (!this.options.enabled) {
this.scrollRect = null;
return 0;
}
this.scrollRect = this.scrollRect ?? this.options.initialRect;
return this.scrollRect[this.options.horizontal ? "width" : "height"];
};
this.getScrollOffset = () => {
if (!this.options.enabled) {
this.scrollOffset = null;
return 0;
}
this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset);
return this.scrollOffset;
};
this.getMeasurementOptions = memo(
() => [
this.options.count,
this.options.paddingStart,
this.options.scrollMargin,
this.options.getItemKey,
this.options.enabled,
this.options.lanes,
this.options.laneAssignmentMode,
this.options.gap
],
(count, paddingStart, scrollMargin, getItemKey, enabled, lanes, laneAssignmentMode, gap) => {
const lanesChanged = this.prevLanes !== void 0 && this.prevLanes !== lanes;
if (lanesChanged) {
this.lanesChangedFlag = true;
}
this.prevLanes = lanes;
this.pendingMin = null;
return {
count,
paddingStart,
scrollMargin,
getItemKey,
enabled,
lanes,
laneAssignmentMode,
gap
};
},
{
key: false
}
);
this.getMeasurements = memo(
() => [this.getMeasurementOptions(), this.itemSizeCacheVersion],
({
count,
paddingStart,
scrollMargin,
getItemKey,
enabled,
lanes,
laneAssignmentMode,
gap
}, _itemSizeCacheVersion) => {
const itemSizeCache = this.itemSizeCache;
if (!enabled) {
this.measurementsCache = [];
this.itemSizeCache.clear();
this.laneAssignments.clear();
return [];
}
if (this.laneAssignments.size > count) {
for (const index of this.laneAssignments.keys()) {
if (index >= count) {
this.laneAssignments.delete(index);
}
}
}
if (this.lanesChangedFlag) {
this.lanesChangedFlag = false;
this.lanesSettling = true;
this.measurementsCache = [];
this.itemSizeCache.clear();
this.laneAssignments.clear();
this.pendingMin = null;
}
if (this.measurementsCache.length === 0 && !this.lanesSettling) {
this.measurementsCache = this.options.initialMeasurementsCache;
this.measurementsCache.forEach((item) => {
this.itemSizeCache.set(item.key, item.size);
});
}
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 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);
const laneLastIndex = new Array(lanes).fill(
void 0
);
const laneEnds = new Float64Array(lanes);
let filledLanes = 0;
for (let m = 0; m < min; m++) {
const item = measurements[m];
if (item) {
if (laneLastIndex[item.lane] === void 0) filledLanes++;
laneLastIndex[item.lane] = m;
laneEnds[item.lane] = item.end;
}
}
for (let i = min; i < count; i++) {
const key = getItemKey(i);
const cachedLane = this.laneAssignments.get(i);
let lane;
let start;
const shouldCacheLane = laneAssignmentMode === "estimate" || itemSizeCache.has(key);
if (cachedLane !== void 0 && this.options.lanes > 1) {
lane = cachedLane;
const prevIndex = laneLastIndex[lane];
const prevInLane = prevIndex !== void 0 ? measurements[prevIndex] : void 0;
start = prevInLane ? prevInLane.end + gap : paddingStart + scrollMargin;
} else if (filledLanes === lanes) {
let bestLane = 0;
let bestEnd = laneEnds[0];
let bestIdx = laneLastIndex[0];
for (let l = 1; l < lanes; l++) {
const e = laneEnds[l];
if (e < bestEnd || e === bestEnd && laneLastIndex[l] < bestIdx) {
bestLane = l;
bestEnd = e;
bestIdx = laneLastIndex[l];
}
}
lane = bestLane;
start = bestEnd + gap;
if (shouldCacheLane) {
this.laneAssignments.set(i, lane);
}
} else {
lane = i % this.options.lanes;
start = paddingStart + scrollMargin;
if (shouldCacheLane) {
this.laneAssignments.set(i, lane);
}
}
const measuredSize = itemSizeCache.get(key);
const size = typeof measuredSize === "number" ? measuredSize : this.options.estimateSize(i);
const end = start + size;
measurements[i] = {
index: i,
start,
size,
end,
key,
lane
};
if (laneLastIndex[lane] === void 0) filledLanes++;
laneLastIndex[lane] = i;
laneEnds[lane] = end;
}
this.measurementsCache = measurements;
return measurements;
},
{
key: process.env.NODE_ENV !== "production" && "getMeasurements",
debug: () => this.options.debug
}
);
this.calculateRange = memo(
() => [
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
this.options.lanes
],
(measurements, outerSize, scrollOffset, lanes) => {
if (measurements.length === 0 || outerSize === 0) {
this.range = null;
return null;
}
this.range = calculateRangeImpl(
measurements,
outerSize,
scrollOffset,
lanes,
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
lanes === 1 && this._flatMeasurements != null ? this._flatMeasurements : null
);
return this.range;
},
{
key: process.env.NODE_ENV !== "production" && "calculateRange",
debug: () => this.options.debug
}
);
this.getVirtualIndexes = memo(
() => {
let startIndex = null;
let endIndex = null;
const range = this.calculateRange();
if (range) {
startIndex = range.startIndex;
endIndex = range.endIndex;
}
this.maybeNotify.updateDeps([this.isScrolling, startIndex, endIndex]);
return [
this.options.rangeExtractor,
this.options.overscan,
this.options.count,
startIndex,
endIndex
];
},
(rangeExtractor, overscan, count, startIndex, endIndex) => {
return startIndex === null || endIndex === null ? [] : rangeExtractor({
startIndex,
endIndex,
overscan,
count
});
},
{
key: process.env.NODE_ENV !== "production" && "getVirtualIndexes",
debug: () => this.options.debug
}
);
this.indexFromElement = (node) => {
const attributeName = this.options.indexAttribute;
const indexStr = node.getAttribute(attributeName);
if (!indexStr) {
console.warn(
`Missing attribute name '${attributeName}={index}' on measured element.`
);
return -1;
}
return parseInt(indexStr, 10);
};
this.shouldMeasureDuringScroll = (index) => {
var _a;
if (!this.scrollState || this.scrollState.behavior !== "smooth") {
return true;
}
const scrollIndex = this.scrollState.index ?? ((_a = this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)) == null ? void 0 : _a.index);
if (scrollIndex !== void 0 && this.range) {
const bufferSize = Math.max(
this.options.overscan,
Math.ceil((this.range.endIndex - this.range.startIndex) / 2)
);
const minIndex = Math.max(0, scrollIndex - bufferSize);
const maxIndex = Math.min(
this.options.count - 1,
scrollIndex + bufferSize
);
return index >= minIndex && index <= maxIndex;
}
return true;
};
this.measureElement = (node) => {
if (!node) {
this.elementsCache.forEach((cached, key2) => {
if (!cached.isConnected) {
this.observer.unobserve(cached);
this.elementsCache.delete(key2);
}
});
return;
}
const index = this.indexFromElement(node);
const key = this.options.getItemKey(index);
const prevNode = this.elementsCache.get(key);
if (prevNode !== node) {
if (prevNode) {
this.observer.unobserve(prevNode);
}
this.observer.observe(node);
this.elementsCache.set(key, node);
}
if ((!this.isScrolling || this.scrollState) && this.shouldMeasureDuringScroll(index)) {
this.resizeItem(index, this.options.measureElement(node, void 0, this));
}
};
this.resizeItem = (index, size) => {
var _a, _b;
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) {
const wasAtEnd = this.options.anchorTo === "end" && ((_a = this.scrollState) == null ? void 0 : _a.behavior) !== "smooth" && this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold;
const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0;
const scrollOffsetWithAdj = this.getScrollOffset() + this.scrollAdjustments;
const isFirstMeasure = !this.itemSizeCache.has(key);
const defaultShouldAdjust = isFirstMeasure ? (
// First measurement: compensate any item whose top sits above the
// fold — the estimate→actual delta must be corrected regardless of
// scroll direction, since the whole estimated block was above it.
itemStart < scrollOffsetWithAdj
) : (
// Re-measurement: only compensate an item that is ENTIRELY above the
// fold. An item that merely *spans* the fold (top above, bottom
// below — e.g. a streaming chat message growing at its bottom)
// changes size *below* the anchor point, so shifting scrollTop by the
// delta would drag the viewport downward on every growth (#1218).
// Also skip during backward scroll to avoid the "items jump while
// scrolling up" cascade.
itemStart + itemSize <= scrollOffsetWithAdj && this.scrollDirection !== "backward"
);
const shouldAdjustScroll = ((_b = this.scrollState) == null ? void 0 : _b.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
) : defaultShouldAdjust);
if (this.pendingMin === null || index < this.pendingMin) {
this.pendingMin = index;
}
this.itemSizeCache.set(key, size);
this.itemSizeCacheVersion++;
if (wasAtEnd) {
this.applyScrollAdjustment(this.getTotalSize() - prevTotalSize);
} else if (shouldAdjustScroll) {
this.applyScrollAdjustment(delta);
}
this.notify(false);
}
};
this.getVirtualItems = memo(
() => [this.getVirtualIndexes(), this.getMeasurements()],
(indexes, measurements) => {
const virtualItems = [];
for (let k = 0, len = indexes.length; k < len; k++) {
const i = indexes[k];
const measurement = measurements[i];
virtualItems.push(measurement);
}
return virtualItems;
},
{
key: process.env.NODE_ENV !== "production" && "getVirtualItems",
debug: () => this.options.debug
}
);
this.getVirtualItemForOffset = (offset) => {
const measurements = this.getMeasurements();
if (measurements.length === 0) {
return void 0;
}
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]);
};
this.getMaxScrollOffset = () => {
if (!this.scrollElement) return 0;
if ("scrollHeight" in this.scrollElement) {
return this.options.horizontal ? this.scrollElement.scrollWidth - this.scrollElement.clientWidth : this.scrollElement.scrollHeight - this.scrollElement.clientHeight;
} else {
const doc = this.scrollElement.document.documentElement;
return this.options.horizontal ? doc.scrollWidth - this.scrollElement.innerWidth : doc.scrollHeight - this.scrollElement.innerHeight;
}
};
this.getVirtualDistanceFromEnd = () => {
return Math.max(
this.getTotalSize() - this.getSize() - this.getScrollOffset(),
0
);
};
this.getDistanceFromEnd = () => {
return Math.max(this.getMaxScrollOffset() - this.getScrollOffset(), 0);
};
this.isAtEnd = (threshold = this.options.scrollEndThreshold) => {
return this.getDistanceFromEnd() <= threshold;
};
this.getOffsetForAlignment = (toOffset, align, itemSize = 0) => {
if (!this.scrollElement) return 0;
const size = this.getSize();
const scrollOffset = this.getScrollOffset();
if (align === "auto") {
align = toOffset >= scrollOffset + size ? "end" : "start";
}
if (align === "center") {
toOffset += (itemSize - size) / 2;
} else if (align === "end") {
toOffset -= size;
}
const maxOffset = this.getMaxScrollOffset();
return Math.max(Math.min(maxOffset, toOffset), 0);
};
this.getOffsetForIndex = (index, align = "auto") => {
index = Math.max(0, Math.min(index, this.options.count - 1));
const size = this.getSize();
const scrollOffset = this.getScrollOffset();
const item = this.measurementsCache[index];
if (!item) return;
if (align === "auto") {
if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
align = "end";
} else if (item.start <= scrollOffset + this.options.scrollPaddingStart) {
align = "start";
} else {
return [scrollOffset, align];
}
}
if (align === "end" && index === this.options.count - 1) {
return [this.getMaxScrollOffset(), align];
}
const toOffset = align === "end" ? item.end + this.options.scrollPaddingEnd : item.start - this.options.scrollPaddingStart;
return [
this.getOffsetForAlignment(toOffset, align, item.size),
align
];
};
this.scrollToOffset = (toOffset, { align = "start", behavior = "auto" } = {}) => {
this._iosDeferredAdjustment = 0;
const offset = this.getOffsetForAlignment(toOffset, align);
const now = this.now();
this.scrollState = {
index: null,
align,
behavior,
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0
};
this._scrollToOffset(offset, { adjustments: void 0, behavior });
this.scheduleScrollReconcile();
};
this.scrollToIndex = (index, {
align: initialAlign = "auto",
behavior = "auto"
} = {}) => {
this._iosDeferredAdjustment = 0;
index = Math.max(0, Math.min(index, this.options.count - 1));
const offsetInfo = this.getOffsetForIndex(index, initialAlign);
if (!offsetInfo) {
return;
}
const [offset, align] = offsetInfo;
const now = this.now();
this.scrollState = {
index,
align,
behavior,
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0
};
this._scrollToOffset(offset, { adjustments: void 0, behavior });
this.scheduleScrollReconcile();
};
this.scrollBy = (delta, { behavior = "auto" } = {}) => {
const offset = this.getScrollOffset() + delta;
const now = this.now();
this.scrollState = {
index: null,
align: "start",
behavior,
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0
};
this._scrollToOffset(offset, { adjustments: void 0, behavior });
this.scheduleScrollReconcile();
};
this.scrollToEnd = ({ behavior = "auto" } = {}) => {
if (this.options.count > 0) {
this.scrollToIndex(this.options.count - 1, {
align: "end",
behavior
});
return;
}
this.scrollToOffset(Math.max(this.getTotalSize() - this.getSize(), 0), {
behavior
});
};
this.getTotalSize = () => {
var _a;
const measurements = this.getMeasurements();
let end;
if (measurements.length === 0) {
end = this.options.paddingStart;
} else if (this.options.lanes === 1) {
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 {
const endByLane = Array(this.options.lanes).fill(null);
let endIndex = measurements.length - 1;
while (endIndex >= 0 && endByLane.some((val) => val === null)) {
const item = measurements[endIndex];
if (endByLane[item.lane] === null) {
endByLane[item.lane] = item.end;
}
endIndex--;
}
end = Math.max(...endByLane.filter((val) => val !== null));
}
return Math.max(
end - this.options.scrollMargin + this.options.paddingEnd,
0
);
};
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, {
adjustments,
behavior
}) => {
this._intendedScrollOffset = offset + (adjustments ?? 0);
this.options.scrollToFn(offset, { behavior, adjustments }, this);
};
this.measure = () => {
this.pendingMin = null;
this.itemSizeCache.clear();
this.laneAssignments.clear();
this.itemSizeCacheVersion++;
this.notify(false);
};
this.setOptions(opts);
}
applyScrollAdjustment(delta, behavior) {
if (delta === 0) return;
if (process.env.NODE_ENV !== "production" && this.options.debug) {
console.info("correction", delta);
}
if (isIOSWebKit() && (this.isScrolling || this._iosTouching || this._iosJustTouchEnded)) {
this._iosDeferredAdjustment += delta;
} else {
this._scrollToOffset(this.getScrollOffset(), {
adjustments: this.scrollAdjustments += delta,
behavior
});
if (this.scrollOffset !== null) {
this.scrollOffset += this.scrollAdjustments;
if (this.scrollOffset < 0) this.scrollOffset = 0;
this.scrollAdjustments = 0;
}
}
}
scheduleScrollReconcile() {
if (!this.targetWindow) {
this.scrollState = null;
return;
}
if (this.rafId != null) return;
this.rafId = this.targetWindow.requestAnimationFrame(() => {
this.rafId = null;
this.reconcileScroll();
});
}
reconcileScroll() {
if (!this.scrollState) return;
const el = this.scrollElement;
if (!el) return;
const MAX_RECONCILE_MS = 5e3;
if (this.now() - this.scrollState.startedAt > MAX_RECONCILE_MS) {
this.scrollState = null;
return;
}
const offsetInfo = this.scrollState.index != null ? this.getOffsetForIndex(this.scrollState.index, this.scrollState.align) : void 0;
const targetOffset = offsetInfo ? offsetInfo[0] : this.scrollState.lastTargetOffset;
const STABLE_FRAMES = 1;
const targetChanged = targetOffset !== this.scrollState.lastTargetOffset;
if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
this.scrollState.stableFrames++;
if (this.scrollState.stableFrames >= STABLE_FRAMES) {
if (this.getScrollOffset() !== targetOffset) {
this._scrollToOffset(targetOffset, {
adjustments: void 0,
behavior: "auto"
});
}
this.scrollState = null;
return;
}
} else {
this.scrollState.stableFrames = 0;
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;
if (!keepSmooth) {
this.scrollState.behavior = "auto";
}
this._scrollToOffset(targetOffset, {
adjustments: void 0,
behavior: keepSmooth ? "smooth" : "auto"
});
}
}
this.scheduleScrollReconcile();
}
}
const findNearestBinarySearch = (low, high, getCurrentValue, value) => {
while (low <= high) {
const middle = (low + high) / 2 | 0;
const currentValue = getCurrentValue(middle);
if (currentValue < value) {
low = middle + 1;
} else if (currentValue > value) {
high = middle - 1;
} else {
return middle;
}
}
if (low > 0) {
return low - 1;
} else {
return 0;
}
};
function findNearestBinarySearchFlat(flat, high, value) {
let low = 0;
while (low <= high) {
const middle = (low + high) / 2 | 0;
const currentValue = flat[middle * 2];
if (currentValue < value) {
low = middle + 1;
} else if (currentValue > value) {
high = middle - 1;
} else {
return middle;
}
}
return low > 0 ? low - 1 : 0;
}
function calculateRangeImpl(measurements, outerSize, scrollOffset, lanes, flat) {
const lastIndex = measurements.length - 1;
if (measurements.length <= lanes) {
return { startIndex: 0, endIndex: lastIndex };
}
if (lanes === 1 && flat !== null) {
const startIndex2 = findNearestBinarySearchFlat(
flat,
lastIndex,
scrollOffset
);
let endIndex2 = startIndex2;
const limit = scrollOffset + outerSize;
while (endIndex2 < lastIndex && flat[endIndex2 * 2] + flat[endIndex2 * 2 + 1] < limit) {
endIndex2++;
}
return { startIndex: startIndex2, endIndex: endIndex2 };
}
const getStart = (index) => measurements[index].start;
let startIndex = findNearestBinarySearch(0, lastIndex, getStart, scrollOffset);
let endIndex = startIndex;
if (lanes === 1) {
while (endIndex < lastIndex && measurements[endIndex].end < scrollOffset + outerSize) {
endIndex++;
}
} else if (lanes > 1) {
const endPerLane = Array(lanes).fill(0);
while (endIndex < lastIndex && endPerLane.some((pos) => pos < scrollOffset + outerSize)) {
const item = measurements[endIndex];
endPerLane[item.lane] = item.end;
endIndex++;
}
const startPerLane = Array(lanes).fill(scrollOffset + outerSize);
while (startIndex >= 0 && startPerLane.some((pos) => pos >= scrollOffset)) {
const item = measurements[startIndex];
startPerLane[item.lane] = item.start;
startIndex--;
}
startIndex = Math.max(0, startIndex - startIndex % lanes);
endIndex = Math.min(lastIndex, endIndex + (lanes - 1 - endIndex % lanes));
}
return { startIndex, endIndex };
}
export {
Virtualizer,
_resetIOSDetectionForTests,
approxEqual,
debounce,
defaultKeyExtractor,
defaultRangeExtractor,
elementScroll,
measureElement,
memo,
notUndefined,
observeElementOffset,
observeElementRect,
observeWindowOffset,
observeWindowRect,
windowScroll
};
//# sourceMappingURL=index.js.map