@studnicky/visible-range
Pure index/offset arithmetic for computing the visible item range of a virtualized list.
Zero DOM dependency — this package never references window, document, or ResizeObserver. The caller wires actual scroll-event listeners and ResizeObserver themselves, and feeds the results in via setScrollOffset() / setViewportSize().
Install
pnpm add @studnicky/visible-rangeUsage
Given a scroll offset, a viewport size, an item-size accessor (fixed or per-index), and an overscan count, VisibleRange computes the inclusive [start, end] index range of items currently visible. Fixed mode (itemSize) shares one size across every item; variable mode (estimateSize) uses a per-index estimator corrected over time via measureItem():
import type { VisibleRangeEntity } from '../src/entities/index.js';
import { VisibleRange } from '../src/index.js';
const fixedModeChanges: VisibleRangeEntity.Type[] = [];
class TelemetryVisibleRange extends VisibleRange {
protected override onRangeChange(range: VisibleRangeEntity.Type): void {
console.log(`[visible-range] range changed to [${String(range.start)}, ${String(range.end)}]`);
fixedModeChanges.push(range);
}
}
// Fixed mode: every row is 40px tall, no DOM — the caller supplies scroll
// offset and viewport size from its own scroll-event/ResizeObserver wiring.
const rows = TelemetryVisibleRange.create({ 'count': 10_000, 'itemSize': 40, 'overscan': 2 });
rows.setViewportSize(400);
rows.setScrollOffset(0);
rows.getRange(); // fires — first call always "changes"
rows.setScrollOffset(0);
rows.getRange(); // no fire — identical range
rows.setScrollOffset(2000);
rows.getRange(); // fires — scrolled past the previous window
console.log('Fixed-mode ranges:', fixedModeChanges);
// Variable mode: rows have an estimated size, corrected as real
// measurements arrive (e.g. after a row renders and reports its height).
const list = VisibleRange.create({
'count': 500,
'estimateSize': () => {
const estimatedItemSize = 16 * 2;
return estimatedItemSize;
},
'overscan': 1
});
list.setViewportSize(200);
list.setScrollOffset(320);
const estimated = list.getRange();
// A caller measuring actual rendered heights corrects the estimate.
for (let i = 0; i < 10; i++) {
list.measureItem(i, 16);
}
const corrected = list.getRange();
console.log('Variable-mode range (estimated):', estimated);
console.log('Variable-mode range (after measureItem corrections):', corrected);Try it
The output shows fixed-mode onRangeChange firing only when the computed range actually moves (not for an identical re-set scroll offset), and variable-mode range estimates shifting once measureItem() corrects the per-index size estimate with real measurements.
Construction
VisibleRange.create({ count, itemSize, overscan? }) selects fixed-size arithmetic. VisibleRange.create({ count, estimateSize, overscan? }) selects variable-size arithmetic. Exactly one sizing strategy is required.
Errors
VisibleRangeError is the root-exported package error thrown when VisibleRange.create() receives invalid or ambiguous config:
import { VisibleRange, VisibleRangeError } from '@studnicky/visible-range';
try {
VisibleRange.create({ count: 100 }); // neither itemSize nor estimateSize supplied
} catch (error) {
if (error instanceof VisibleRangeError) {
console.error(error.code); // 'visibleRange.invalidConfig'
}
}It carries a fixed code of 'visibleRange.invalidConfig' and retryable: false. It is thrown when:
- neither
itemSizenorestimateSizeis supplied, - both
itemSizeandestimateSizeare supplied.
Observability hooks
Subclass VisibleRange and override the protected hook to inject trace logging, metrics, or side-effects at the exact stage where they are needed. Hooks should stay fast and non-blocking; observer-hook failures are contained so range computation still wins.
| Hook | When it fires | Args |
|---|---|---|
onRangeChange(range) | At the end of getRange(), only when the computed range differs from the preceding range. The first call always fires. | range: VisibleRangeEntity.Type |
The base class never calls any logger or metrics library. All hooks are no-ops by default.
Import VisibleRange, VisibleRangeEntity, VisibleRangeConfigInterface, and VisibleRangeError from @studnicky/visible-range. The package declares separate root, entity, and interface import surfaces.
Entities
@studnicky/visible-range/entities exports every schema namespace in src/entities.
import { VisibleRangeEntity } from '@studnicky/visible-range/entities';Interfaces
@studnicky/visible-range/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.
import type { VisibleRangeConfigInterface } from '@studnicky/visible-range/interfaces';Exports
| Symbol | Purpose | Import path |
|---|---|---|
VisibleRange | Provides visible range functionality. | @studnicky/visible-range |
VisibleRangeError | Represents visible range failures. | @studnicky/visible-range |