Summary
With @legendapp/list@3.3.11, sliding-window pagination keeps the data array bounded but retains measurement-cache entries for removed item IDs across data updates.
For example, keep 400 rows, remove the first 20, append 20 new rows with new IDs, and repeat. After 100 replacements, data.length and indexByKey.size remain 400, but both state.sizes and state.sizesKnown contain 2,400 entries, including 2,000 IDs no longer present in the data.
This concerns retained ID/size entries, not evidence of retained row components or image buffers.
Environment
@legendapp/list: 3.3.11, reproduced with a clean installation of the published package.
- Standalone regression: Node 24.16.0, TypeScript 6.0.3 used only to extract the published functions.
- Application context: React Native 0.85.3, React 19.2.3, Expo 56, Hermes, iOS 18.5 simulator.
- The core-level reproduction below produces the same result across all six published native/web CJS/ESM bundles. This is not a claim of interactive testing on every platform.
Expected behavior
A long-lived feed should be able to discard measurements for IDs that have left its retained data window, while preserving measurements for rows still present.
If keeping measurements for removed IDs is intentional, an opt-in pruning policy or supported selective eviction API would also address this use case. Clearing all measurements on every page eviction would discard valid measurements for retained variable-height rows.
Steps to reproduce
- Initialize 400 rows with unique, stable IDs and known measurements.
- Replace the data with rows 20–419, then 40–439, etc., preserving retained IDs and adding 20 new IDs each time.
- Run the normal data-change cache reset and item-position update.
- Inspect both size caches after 100 replacements.
The script below isolates the actual resetLayoutCachesForDataChange and updateItemPositions functions from the published bundles. It stubs platform/measurement IO and seeds measurements to model rows already measured; it does not mount React components or measure process heap usage. No application backend, query library, images, or network feed is involved.
In an empty directory:
npm init -y
npm install --ignore-scripts --legacy-peer-deps --no-audit --no-fund @legendapp/list@3.3.11 typescript@6.0.3
# Save the script below as repro.mjs.
node repro.mjs
Standalone repro.mjs
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { runInNewContext } from "node:vm";
import ts from "typescript";
const require = createRequire(import.meta.url);
const packageDirectory = process.argv[2] ?? dirname(require.resolve("@legendapp/list/react-native"));
// Run the published layout functions; stub platform/measurement IO only.
function loadLayoutEngine(filename) {
const source = readFileSync(join(packageDirectory, filename), "utf8");
const ast = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true);
const functions = ["resetLayoutCachesForDataChange", "updateItemPositions"].map((name) => {
const declaration = ast.statements.find(
(statement) => ts.isFunctionDeclaration(statement) && statement.name?.text === name,
);
assert.ok(declaration, `Missing installed LegendList function: ${name}`);
return declaration.getText(ast);
});
return runInNewContext(`${functions.join("\n")}\n({resetLayoutCachesForDataChange, updateItemPositions})`, {
IS_DEV: false,
Platform: { OS: filename.startsWith("react.") || filename.includes(".web.") ? "web" : "ios" },
peek$: () => 1,
getScrollVelocity: () => 0,
getId: (state, index) => {
const id = state.props.data[index].id;
state.idCache[index] = id;
return id;
},
getItemSize: (ctx, id, index, item) => {
const size = ctx.state.sizesKnown.get(id) ?? item.height;
ctx.state.sizes.set(id, size);
return size;
},
updateTotalSize: () => {},
});
}
function createContext() {
return {
positionListeners: new Map(),
state: {
columns: [],
columnSpans: [],
indexByKey: new Map(),
positions: [],
idCache: [],
sizes: new Map(),
sizesKnown: new Map(),
lastScrollDelta: 0,
scrollLength: 800,
scrollAdjustHandler: { getAdjust: () => 0 },
props: { data: [] },
},
};
}
function rows(start, count) {
return Array.from({ length: count }, (_, offset) => ({
id: `item-${start + offset}`,
height: 100 + ((start + offset) % 9) * 73.5,
}));
}
function applyData(engine, context, data) {
context.state.props.data = data;
engine.resetLayoutCachesForDataChange(context.state);
engine.updateItemPositions(context, true);
}
const results = [];
for (const filename of [
"react-native.js",
"react-native.mjs",
"react-native.web.js",
"react-native.web.mjs",
"react.js",
"react.mjs",
]) {
const engine = loadLayoutEngine(filename);
const context = createContext();
const { state } = context;
for (let cycle = 0; cycle <= 100; cycle++) {
const data = rows(cycle * 20, 400);
// Model rows that have been measured. Only new IDs allocate entries.
for (const item of data) {
state.sizes.set(item.id, item.height);
state.sizesKnown.set(item.id, item.height);
}
applyData(engine, context, data);
}
const liveIds = new Set(state.props.data.map((item) => item.id));
results.push({
bundle: filename,
data: state.props.data.length,
indexByKey: state.indexByKey.size,
sizes: state.sizes.size,
sizesKnown: state.sizesKnown.size,
removedKeys: [...state.sizesKnown.keys()].filter((id) => !liveIds.has(id)).length,
});
}
console.table(results);
if (results.some((row) => row.sizes > row.data || row.sizesKnown > row.data)) {
process.exitCode = 1;
}
Actual result
The script exits with code 1 and reports:
| Bundle |
Data |
ID index |
sizes |
sizesKnown |
Removed IDs in sizesKnown |
| react-native.js |
400 |
400 |
2400 |
2400 |
2000 |
| react-native.mjs |
400 |
400 |
2400 |
2400 |
2000 |
| react-native.web.js |
400 |
400 |
2400 |
2400 |
2000 |
| react-native.web.mjs |
400 |
400 |
2400 |
2400 |
2000 |
| react.js |
400 |
400 |
2400 |
2400 |
2000 |
| react.mjs |
400 |
400 |
2400 |
2400 |
2000 |
Investigation and proposed direction
In this version, resetLayoutCachesForDataChange clears the ID/index and position bookkeeping, but retains both size maps. updateItemPositions rebuilds the current ID index without removing stale measurement keys.
A small local fix, placed after the positioning loop has rebuilt indexByKey, is:
if (dataChanged) {
for (const cache of [state.sizes, sizesKnown]) {
for (const key of cache.keys()) {
if (!indexByKey.has(key)) {
cache.delete(key);
}
}
}
}
With this change, the same reproduction exits with code 0: both caches remain at 400 entries and contain no removed IDs. Separate regression checks also cover retaining exact live-row measurements/positions, append-only updates, dataset replacement, and clearing the data.
Following the README's request to open an issue before proposing a fix: is automatic pruning on data changes appropriate, or would an opt-in policy be preferable for consumers that intentionally reintroduce removed IDs?
Summary
With
@legendapp/list@3.3.11, sliding-window pagination keeps the data array bounded but retains measurement-cache entries for removed item IDs across data updates.For example, keep 400 rows, remove the first 20, append 20 new rows with new IDs, and repeat. After 100 replacements,
data.lengthandindexByKey.sizeremain 400, but bothstate.sizesandstate.sizesKnowncontain 2,400 entries, including 2,000 IDs no longer present in the data.This concerns retained ID/size entries, not evidence of retained row components or image buffers.
Environment
@legendapp/list: 3.3.11, reproduced with a clean installation of the published package.Expected behavior
A long-lived feed should be able to discard measurements for IDs that have left its retained data window, while preserving measurements for rows still present.
If keeping measurements for removed IDs is intentional, an opt-in pruning policy or supported selective eviction API would also address this use case. Clearing all measurements on every page eviction would discard valid measurements for retained variable-height rows.
Steps to reproduce
The script below isolates the actual
resetLayoutCachesForDataChangeandupdateItemPositionsfunctions from the published bundles. It stubs platform/measurement IO and seeds measurements to model rows already measured; it does not mount React components or measure process heap usage. No application backend, query library, images, or network feed is involved.In an empty directory:
npm init -y npm install --ignore-scripts --legacy-peer-deps --no-audit --no-fund @legendapp/list@3.3.11 typescript@6.0.3 # Save the script below as repro.mjs. node repro.mjsStandalone repro.mjs
Actual result
The script exits with code 1 and reports:
Investigation and proposed direction
In this version,
resetLayoutCachesForDataChangeclears the ID/index and position bookkeeping, but retains both size maps.updateItemPositionsrebuilds the current ID index without removing stale measurement keys.A small local fix, placed after the positioning loop has rebuilt
indexByKey, is:With this change, the same reproduction exits with code 0: both caches remain at 400 entries and contain no removed IDs. Separate regression checks also cover retaining exact live-row measurements/positions, append-only updates, dataset replacement, and clearing the data.
Following the README's request to open an issue before proposing a fix: is automatic pruning on data changes appropriate, or would an opt-in policy be preferable for consumers that intentionally reintroduce removed IDs?