@ccheever/exact-list-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@ccheever/exact-list-core",
3
+ "version": "0.1.0",
4
+ "description": "Framework-neutral virtual list core for Exact",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./src/index.ts",
11
+ "types": "./src/index.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "bun test"
16
+ }
17
+ }
@@ -0,0 +1,153 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import {
4
+ computeVirtualItemsFromPrepared,
5
+ computeVirtualItems,
6
+ getEstimatedOffsetForIndex,
7
+ getScrollOffsetAfterMeasurement,
8
+ measurementsFromSnapshot,
9
+ createMeasurementSnapshot,
10
+ prepareVirtualList,
11
+ } from '../index.ts';
12
+
13
+ describe('computeVirtualItems', () => {
14
+ it('mounts only the overscanned window', () => {
15
+ const output = computeVirtualItems({
16
+ count: 10_000,
17
+ scrollOffset: 5_000,
18
+ viewportSize: 500,
19
+ drawDistance: 250,
20
+ estimatedItemSize: 50,
21
+ getKey: (index) => `row-${index}`,
22
+ });
23
+
24
+ expect(output.totalSize).toBe(500_000);
25
+ expect(output.visibleRange).toEqual({ startIndex: 100, endIndex: 109 });
26
+ expect(output.overscanRange).toEqual({ startIndex: 95, endIndex: 114 });
27
+ expect(output.items.length).toBe(20);
28
+ expect(output.diagnostics.itemCount).toBe(10_000);
29
+ expect(output.diagnostics.mountedItemCount).toBe(20);
30
+ });
31
+
32
+ it('uses measured sizes when available', () => {
33
+ const measurements = new Map<string, number>([
34
+ ['row-0', 100],
35
+ ['row-1', 25],
36
+ ]);
37
+
38
+ const output = computeVirtualItems({
39
+ count: 4,
40
+ scrollOffset: 0,
41
+ viewportSize: 400,
42
+ drawDistance: 0,
43
+ estimatedItemSize: 50,
44
+ measurements,
45
+ getKey: (index) => `row-${index}`,
46
+ });
47
+
48
+ expect(output.totalSize).toBe(225);
49
+ expect(output.items.map((item) => [item.key, item.start, item.size, item.measured])).toEqual([
50
+ ['row-0', 0, 100, true],
51
+ ['row-1', 100, 25, true],
52
+ ['row-2', 125, 50, false],
53
+ ['row-3', 175, 50, false],
54
+ ]);
55
+ expect(output.diagnostics.measuredItemCount).toBe(2);
56
+ expect(output.diagnostics.measurementCacheHitRate).toBe(0.5);
57
+ });
58
+
59
+ it('can keep specific indices mounted outside the window', () => {
60
+ const output = computeVirtualItems({
61
+ count: 200,
62
+ scrollOffset: 1_000,
63
+ viewportSize: 100,
64
+ drawDistance: 0,
65
+ estimatedItemSize: 10,
66
+ alwaysRenderIndices: [0, 199],
67
+ getKey: (index) => `row-${index}`,
68
+ });
69
+
70
+ expect(output.items.some((item) => item.index === 0)).toBe(true);
71
+ expect(output.items.some((item) => item.index === 199)).toBe(true);
72
+ expect(output.diagnostics.alwaysMountedCount).toBe(2);
73
+ });
74
+ });
75
+
76
+ describe('measurement snapshots', () => {
77
+ it('round-trips measurement entries', () => {
78
+ const snapshot = createMeasurementSnapshot(new Map([
79
+ ['a', 12],
80
+ ['b', 34],
81
+ ]));
82
+ const measurements = measurementsFromSnapshot(snapshot);
83
+
84
+ expect(measurements.get('a')).toBe(12);
85
+ expect(measurements.get('b')).toBe(34);
86
+ });
87
+
88
+ it('estimates offsets from measured and estimated sizes', () => {
89
+ const offset = getEstimatedOffsetForIndex({
90
+ count: 5,
91
+ estimatedItemSize: 10,
92
+ measurements: new Map([['row-1', 25]]),
93
+ getKey: (index) => `row-${index}`,
94
+ }, 3);
95
+
96
+ expect(offset).toBe(45);
97
+ });
98
+ });
99
+
100
+ describe('prepared list state', () => {
101
+ it('reuses prepared offsets for different scroll windows', () => {
102
+ const prepared = prepareVirtualList({
103
+ count: 1_000,
104
+ estimatedItemSize: 20,
105
+ getKey: (index) => `row-${index}`,
106
+ });
107
+
108
+ const first = computeVirtualItemsFromPrepared(prepared, {
109
+ scrollOffset: 0,
110
+ viewportSize: 100,
111
+ drawDistance: 0,
112
+ });
113
+ const second = computeVirtualItemsFromPrepared(prepared, {
114
+ scrollOffset: 10_000,
115
+ viewportSize: 100,
116
+ drawDistance: 0,
117
+ });
118
+
119
+ expect(prepared.totalSize).toBe(20_000);
120
+ expect(first.visibleRange).toEqual({ startIndex: 0, endIndex: 4 });
121
+ expect(second.visibleRange).toEqual({ startIndex: 500, endIndex: 504 });
122
+ });
123
+
124
+ it('preserves the visible anchor when an above-viewport item is measured', () => {
125
+ const prepared = prepareVirtualList({
126
+ count: 10,
127
+ estimatedItemSize: 50,
128
+ getKey: (index) => `row-${index}`,
129
+ });
130
+
131
+ expect(getScrollOffsetAfterMeasurement(prepared, 'row-1', 250, 80)).toEqual({
132
+ key: 'row-1',
133
+ affected: true,
134
+ delta: 30,
135
+ nextScrollOffset: 280,
136
+ });
137
+ });
138
+
139
+ it('does not adjust scroll when a below-viewport item is measured', () => {
140
+ const prepared = prepareVirtualList({
141
+ count: 10,
142
+ estimatedItemSize: 50,
143
+ getKey: (index) => `row-${index}`,
144
+ });
145
+
146
+ expect(getScrollOffsetAfterMeasurement(prepared, 'row-8', 250, 80)).toEqual({
147
+ key: 'row-8',
148
+ affected: false,
149
+ delta: 30,
150
+ nextScrollOffset: 250,
151
+ });
152
+ });
153
+ });
package/src/index.ts ADDED
@@ -0,0 +1,406 @@
1
+ export type ListAxis = 'vertical' | 'horizontal';
2
+
3
+ export interface ListVisibleRange {
4
+ readonly startIndex: number;
5
+ readonly endIndex: number;
6
+ }
7
+
8
+ export interface ListOverscanRange {
9
+ readonly startIndex: number;
10
+ readonly endIndex: number;
11
+ }
12
+
13
+ export interface VirtualListItem {
14
+ readonly index: number;
15
+ readonly key: string;
16
+ readonly type: string | null;
17
+ readonly start: number;
18
+ readonly size: number;
19
+ readonly measured: boolean;
20
+ }
21
+
22
+ export interface ListMeasurementEntry {
23
+ readonly key: string;
24
+ readonly size: number;
25
+ readonly type?: string | null;
26
+ }
27
+
28
+ export interface ListMeasurementSnapshot {
29
+ readonly entries: readonly ListMeasurementEntry[];
30
+ }
31
+
32
+ export interface ListDiagnostics {
33
+ readonly virtualized: boolean;
34
+ readonly itemCount: number;
35
+ readonly mountedItemCount: number;
36
+ readonly visibleStartIndex: number;
37
+ readonly visibleEndIndex: number;
38
+ readonly overscanStartIndex: number;
39
+ readonly overscanEndIndex: number;
40
+ readonly measuredItemCount: number;
41
+ readonly estimatedItemCount: number;
42
+ readonly averageItemSize: number;
43
+ readonly measurementCacheHitRate: number;
44
+ readonly blankAreaPx: number;
45
+ readonly droppedFrameEstimate: number | null;
46
+ readonly alwaysMountedCount: number;
47
+ readonly recycledCellCount: number;
48
+ }
49
+
50
+ export interface ListVirtualizerInput {
51
+ readonly count: number;
52
+ readonly scrollOffset: number;
53
+ readonly viewportSize: number;
54
+ readonly drawDistance: number;
55
+ readonly estimatedItemSize: number;
56
+ readonly measurements?: ReadonlyMap<string, number>;
57
+ readonly alwaysRenderIndices?: readonly number[];
58
+ getKey(index: number): string;
59
+ getType?: (index: number) => string | null;
60
+ getEstimatedSize?: (index: number, type: string | null) => number;
61
+ getFixedSize?: (index: number, type: string | null) => number | null | undefined;
62
+ }
63
+
64
+ export interface ListVirtualizerOutput {
65
+ readonly totalSize: number;
66
+ readonly items: readonly VirtualListItem[];
67
+ readonly visibleRange: ListVisibleRange;
68
+ readonly overscanRange: ListOverscanRange;
69
+ readonly diagnostics: ListDiagnostics;
70
+ }
71
+
72
+ export interface PreparedListItem {
73
+ readonly key: string;
74
+ readonly type: string | null;
75
+ readonly size: number;
76
+ readonly measured: boolean;
77
+ readonly start: number;
78
+ readonly end: number;
79
+ }
80
+
81
+ export interface PreparedListState {
82
+ readonly count: number;
83
+ readonly totalSize: number;
84
+ readonly records: readonly PreparedListItem[];
85
+ readonly measuredItemCount: number;
86
+ }
87
+
88
+ export interface ListMeasurementAnchorAdjustment {
89
+ readonly key: string;
90
+ readonly affected: boolean;
91
+ readonly delta: number;
92
+ readonly nextScrollOffset: number;
93
+ }
94
+
95
+ const DEFAULT_ITEM_SIZE = 64;
96
+
97
+ function normalizeCount(count: number): number {
98
+ return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
99
+ }
100
+
101
+ function normalizeSize(size: number | null | undefined, fallback: number): number {
102
+ return Number.isFinite(size) && size != null && size > 0 ? size : fallback;
103
+ }
104
+
105
+ function emptyDiagnostics(virtualized: boolean): ListDiagnostics {
106
+ return {
107
+ virtualized,
108
+ itemCount: 0,
109
+ mountedItemCount: 0,
110
+ visibleStartIndex: -1,
111
+ visibleEndIndex: -1,
112
+ overscanStartIndex: -1,
113
+ overscanEndIndex: -1,
114
+ measuredItemCount: 0,
115
+ estimatedItemCount: 0,
116
+ averageItemSize: 0,
117
+ measurementCacheHitRate: 0,
118
+ blankAreaPx: 0,
119
+ droppedFrameEstimate: null,
120
+ alwaysMountedCount: 0,
121
+ recycledCellCount: 0,
122
+ };
123
+ }
124
+
125
+ export function prepareVirtualList(input: Omit<ListVirtualizerInput, 'scrollOffset' | 'viewportSize' | 'drawDistance' | 'alwaysRenderIndices'>): PreparedListState {
126
+ const count = normalizeCount(input.count);
127
+ const baseEstimate = normalizeSize(input.estimatedItemSize, DEFAULT_ITEM_SIZE);
128
+ const records: PreparedListItem[] = [];
129
+ let offset = 0;
130
+ let measuredItemCount = 0;
131
+
132
+ for (let index = 0; index < count; index += 1) {
133
+ const key = input.getKey(index);
134
+ const type = input.getType?.(index) ?? null;
135
+ const fixedSize = input.getFixedSize?.(index, type);
136
+ const measuredSize = fixedSize == null ? input.measurements?.get(key) : undefined;
137
+ const estimate = input.getEstimatedSize?.(index, type) ?? baseEstimate;
138
+ const size = normalizeSize(fixedSize ?? measuredSize ?? estimate, baseEstimate);
139
+ const measured = fixedSize != null || measuredSize != null;
140
+ if (measured) {
141
+ measuredItemCount += 1;
142
+ }
143
+
144
+ records.push({
145
+ key,
146
+ type,
147
+ size,
148
+ measured,
149
+ start: offset,
150
+ end: offset + size,
151
+ });
152
+ offset += size;
153
+ }
154
+
155
+ return {
156
+ count,
157
+ totalSize: offset,
158
+ records,
159
+ measuredItemCount,
160
+ };
161
+ }
162
+
163
+ function findFirstIntersecting(records: readonly PreparedListItem[], offset: number): number {
164
+ if (records.length === 0) {
165
+ return -1;
166
+ }
167
+
168
+ let low = 0;
169
+ let high = records.length - 1;
170
+ let result = records.length;
171
+
172
+ while (low <= high) {
173
+ const mid = Math.floor((low + high) / 2);
174
+ if (records[mid].end > offset) {
175
+ result = mid;
176
+ high = mid - 1;
177
+ } else {
178
+ low = mid + 1;
179
+ }
180
+ }
181
+
182
+ return result >= records.length ? records.length - 1 : result;
183
+ }
184
+
185
+ function findLastIntersecting(records: readonly PreparedListItem[], offset: number): number {
186
+ if (records.length === 0) {
187
+ return -1;
188
+ }
189
+
190
+ let low = 0;
191
+ let high = records.length - 1;
192
+ let result = -1;
193
+
194
+ while (low <= high) {
195
+ const mid = Math.floor((low + high) / 2);
196
+ if (records[mid].start < offset) {
197
+ result = mid;
198
+ low = mid + 1;
199
+ } else {
200
+ high = mid - 1;
201
+ }
202
+ }
203
+
204
+ return result < 0 ? 0 : result;
205
+ }
206
+
207
+ function rangeItems(
208
+ records: readonly PreparedListItem[],
209
+ startIndex: number,
210
+ endIndex: number,
211
+ ): VirtualListItem[] {
212
+ if (startIndex < 0 || endIndex < startIndex) {
213
+ return [];
214
+ }
215
+
216
+ const items: VirtualListItem[] = [];
217
+ for (let index = startIndex; index <= endIndex; index += 1) {
218
+ const record = records[index];
219
+ items.push({
220
+ index,
221
+ key: record.key,
222
+ type: record.type,
223
+ start: record.start,
224
+ size: record.size,
225
+ measured: record.measured,
226
+ });
227
+ }
228
+ return items;
229
+ }
230
+
231
+ function appendAlwaysRenderedItems(
232
+ items: VirtualListItem[],
233
+ records: readonly PreparedListItem[],
234
+ alwaysRenderIndices: readonly number[] | undefined,
235
+ ): number {
236
+ if (!alwaysRenderIndices || alwaysRenderIndices.length === 0) {
237
+ return 0;
238
+ }
239
+
240
+ const existing = new Set(items.map((item) => item.index));
241
+ let added = 0;
242
+ for (const index of alwaysRenderIndices) {
243
+ if (index < 0 || index >= records.length || existing.has(index)) {
244
+ continue;
245
+ }
246
+ const record = records[index];
247
+ items.push({
248
+ index,
249
+ key: record.key,
250
+ type: record.type,
251
+ start: record.start,
252
+ size: record.size,
253
+ measured: record.measured,
254
+ });
255
+ existing.add(index);
256
+ added += 1;
257
+ }
258
+ items.sort((left, right) => left.index - right.index);
259
+ return added;
260
+ }
261
+
262
+ export function computeVirtualItems(input: ListVirtualizerInput): ListVirtualizerOutput {
263
+ const prepared = prepareVirtualList(input);
264
+ return computeVirtualItemsFromPrepared(prepared, {
265
+ scrollOffset: input.scrollOffset,
266
+ viewportSize: input.viewportSize,
267
+ drawDistance: input.drawDistance,
268
+ alwaysRenderIndices: input.alwaysRenderIndices,
269
+ });
270
+ }
271
+
272
+ export function computeVirtualItemsFromPrepared(
273
+ prepared: PreparedListState,
274
+ options: {
275
+ readonly scrollOffset: number;
276
+ readonly viewportSize: number;
277
+ readonly drawDistance: number;
278
+ readonly alwaysRenderIndices?: readonly number[];
279
+ },
280
+ ): ListVirtualizerOutput {
281
+ const count = prepared.count;
282
+ if (count === 0) {
283
+ return {
284
+ totalSize: 0,
285
+ items: [],
286
+ visibleRange: { startIndex: -1, endIndex: -1 },
287
+ overscanRange: { startIndex: -1, endIndex: -1 },
288
+ diagnostics: emptyDiagnostics(true),
289
+ };
290
+ }
291
+
292
+ const records = prepared.records;
293
+ const totalSize = prepared.totalSize;
294
+ const viewportSize = Math.max(0, options.viewportSize);
295
+ const scrollOffset = Math.max(0, Math.min(options.scrollOffset, Math.max(0, totalSize - viewportSize)));
296
+ const visibleStart = findFirstIntersecting(records, scrollOffset);
297
+ const visibleEnd = findLastIntersecting(records, scrollOffset + viewportSize);
298
+ const drawDistance = Math.max(0, options.drawDistance);
299
+ const overscanStart = findFirstIntersecting(records, Math.max(0, scrollOffset - drawDistance));
300
+ const overscanEnd = findLastIntersecting(records, Math.min(totalSize, scrollOffset + viewportSize + drawDistance));
301
+ const items = rangeItems(records, overscanStart, overscanEnd);
302
+ const alwaysMountedCount = appendAlwaysRenderedItems(items, records, options.alwaysRenderIndices);
303
+ const measuredItemCount = prepared.measuredItemCount;
304
+ const mountedMeasuredItemCount = items.reduce((sum, item) => sum + (item.measured ? 1 : 0), 0);
305
+ const averageItemSize = totalSize / count;
306
+ const viewportStart = scrollOffset;
307
+ const viewportEnd = scrollOffset + viewportSize;
308
+ const coveredStart = items.length === 0
309
+ ? viewportStart
310
+ : Math.max(viewportStart, Math.min(...items.map((item) => item.start)));
311
+ const coveredEnd = items.length === 0
312
+ ? viewportStart
313
+ : Math.min(viewportEnd, Math.max(...items.map((item) => item.start + item.size)));
314
+ const blankAreaPx = Math.max(0, viewportSize - Math.max(0, coveredEnd - coveredStart));
315
+
316
+ return {
317
+ totalSize,
318
+ items,
319
+ visibleRange: {
320
+ startIndex: visibleStart,
321
+ endIndex: visibleEnd,
322
+ },
323
+ overscanRange: {
324
+ startIndex: overscanStart,
325
+ endIndex: overscanEnd,
326
+ },
327
+ diagnostics: {
328
+ virtualized: true,
329
+ itemCount: count,
330
+ mountedItemCount: items.length,
331
+ visibleStartIndex: visibleStart,
332
+ visibleEndIndex: visibleEnd,
333
+ overscanStartIndex: overscanStart,
334
+ overscanEndIndex: overscanEnd,
335
+ measuredItemCount,
336
+ estimatedItemCount: count - measuredItemCount,
337
+ averageItemSize,
338
+ measurementCacheHitRate: items.length === 0 ? 0 : mountedMeasuredItemCount / items.length,
339
+ blankAreaPx,
340
+ droppedFrameEstimate: null,
341
+ alwaysMountedCount,
342
+ recycledCellCount: 0,
343
+ },
344
+ };
345
+ }
346
+
347
+ export function getEstimatedOffsetForIndex(
348
+ input: Omit<ListVirtualizerInput, 'scrollOffset' | 'viewportSize' | 'drawDistance'>,
349
+ targetIndex: number,
350
+ ): number {
351
+ const count = normalizeCount(input.count);
352
+ const clampedTarget = Math.max(0, Math.min(Math.floor(targetIndex), count));
353
+ const { records } = prepareVirtualList(input);
354
+ return records[clampedTarget]?.start ?? records[records.length - 1]?.end ?? 0;
355
+ }
356
+
357
+ export function getScrollOffsetAfterMeasurement(
358
+ prepared: PreparedListState,
359
+ key: string,
360
+ currentScrollOffset: number,
361
+ nextSize: number,
362
+ ): ListMeasurementAnchorAdjustment {
363
+ const record = prepared.records.find((record) => record.key === key);
364
+ if (!record || !Number.isFinite(nextSize) || nextSize <= 0) {
365
+ return {
366
+ key,
367
+ affected: false,
368
+ delta: 0,
369
+ nextScrollOffset: currentScrollOffset,
370
+ };
371
+ }
372
+
373
+ const delta = nextSize - record.size;
374
+ const affected = Math.abs(delta) >= 0.5 && record.start < currentScrollOffset;
375
+ return {
376
+ key,
377
+ affected,
378
+ delta,
379
+ nextScrollOffset: affected ? Math.max(0, currentScrollOffset + delta) : currentScrollOffset,
380
+ };
381
+ }
382
+
383
+ export function createMeasurementSnapshot(
384
+ measurements: ReadonlyMap<string, number>,
385
+ getType?: (key: string) => string | null | undefined,
386
+ ): ListMeasurementSnapshot {
387
+ return {
388
+ entries: [...measurements.entries()].map(([key, size]) => ({
389
+ key,
390
+ size,
391
+ type: getType?.(key) ?? null,
392
+ })),
393
+ };
394
+ }
395
+
396
+ export function measurementsFromSnapshot(
397
+ snapshot: ListMeasurementSnapshot | undefined,
398
+ ): Map<string, number> {
399
+ const measurements = new Map<string, number>();
400
+ for (const entry of snapshot?.entries ?? []) {
401
+ if (entry.key.length > 0 && Number.isFinite(entry.size) && entry.size > 0) {
402
+ measurements.set(entry.key, entry.size);
403
+ }
404
+ }
405
+ return measurements;
406
+ }