Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions src/__tests__/RecyclerViewManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,166 @@ describe("RecyclerViewManager", () => {
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
});

describe("processDataUpdate — layout invalidation on in-place data swap", () => {
interface Item {
id: number;
}
const keyExtractor = (item: Item) => `item-${item.id}`;

const createGridManager = (data: Item[], overrides = {}) => {
const props = {
data,
renderItem: jest.fn(),
keyExtractor,
numColumns: 2,
...overrides,
} as FlashListProps<Item>;
const manager = new RecyclerViewManager<Item>(props);
manager.processDataUpdate();
manager.updateLayoutParams({ width: 400, height: 900 }, 0);
manager.processDataUpdate();
return manager;
};

const markAsMeasured = (
manager: RecyclerViewManager<Item>,
indices: number[],
height: number
) => {
for (const i of indices) {
const layout = manager.getLayout(i);
layout.isHeightMeasured = true;
layout.minHeight = height;
layout.height = height;
}
};

it("invalidates cached height for indices whose key changed", () => {
const initialData: Item[] = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
const manager = createGridManager(initialData);
markAsMeasured(manager, [0, 1, 2, 3], 200);

// Replace items at index 0 and 2 (different identities)
const swappedData: Item[] = [
{ id: 100 },
initialData[1],
{ id: 300 },
initialData[3],
];
manager.updateProps({
...manager.props,
data: swappedData,
});
manager.processDataUpdate();

expect(manager.getLayout(0).isHeightMeasured).toBe(false);
expect(manager.getLayout(0).minHeight).toBeUndefined();
expect(manager.getLayout(2).isHeightMeasured).toBe(false);
expect(manager.getLayout(2).minHeight).toBeUndefined();

// Unchanged identities keep their cached measurement
expect(manager.getLayout(1).isHeightMeasured).toBe(true);
expect(manager.getLayout(1).minHeight).toBe(200);
expect(manager.getLayout(3).isHeightMeasured).toBe(true);
expect(manager.getLayout(3).minHeight).toBe(200);
});

it("invalidates every index when the whole array is replaced", () => {
const initialData: Item[] = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
const manager = createGridManager(initialData);
markAsMeasured(manager, [0, 1, 2, 3], 200);

// Category switch — entirely different items at every position
const swappedData: Item[] = [
{ id: 10 },
{ id: 20 },
{ id: 30 },
{ id: 40 },
];
manager.updateProps({
...manager.props,
data: swappedData,
});
manager.processDataUpdate();

for (let i = 0; i < swappedData.length; i++) {
expect(manager.getLayout(i).isHeightMeasured).toBe(false);
expect(manager.getLayout(i).minHeight).toBeUndefined();
}
});

it("does not invalidate when the same items are re-emitted at the same positions", () => {
const initialData: Item[] = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
const manager = createGridManager(initialData);
markAsMeasured(manager, [0, 1, 2, 3], 200);

// New array reference, same identities — e.g. a query refetch returning equal data
const sameData: Item[] = initialData.map((item) => ({ id: item.id }));
manager.updateProps({
...manager.props,
data: sameData,
});
manager.processDataUpdate();

for (let i = 0; i < sameData.length; i++) {
expect(manager.getLayout(i).isHeightMeasured).toBe(true);
expect(manager.getLayout(i).minHeight).toBe(200);
}
});

it("does not invalidate measured heights for indices that survived an append", () => {
const initialData: Item[] = [{ id: 1 }, { id: 2 }];
const manager = createGridManager(initialData);
markAsMeasured(manager, [0, 1], 200);

const appendedData: Item[] = [...initialData, { id: 3 }, { id: 4 }];
manager.updateProps({
...manager.props,
data: appendedData,
});
manager.processDataUpdate();

// Pre-existing indices: keys unchanged, so invalidation must not reset
// `isHeightMeasured` or `height` (the per-row normalization that runs
// when new indices are appended is a separate concern).
expect(manager.getLayout(0).isHeightMeasured).toBe(true);
expect(manager.getLayout(0).height).toBe(200);
expect(manager.getLayout(1).isHeightMeasured).toBe(true);
expect(manager.getLayout(1).height).toBe(200);
});

it("is a no-op when keyExtractor is not provided", () => {
const initialData: Item[] = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
const props = {
data: initialData,
renderItem: jest.fn(),
numColumns: 2,
} as FlashListProps<Item>;
const manager = new RecyclerViewManager<Item>(props);
manager.processDataUpdate();
manager.updateLayoutParams({ width: 400, height: 900 }, 0);
manager.processDataUpdate();
markAsMeasured(manager, [0, 1, 2, 3], 200);

const swappedData: Item[] = [
{ id: 10 },
{ id: 20 },
{ id: 30 },
{ id: 40 },
];
manager.updateProps({
...manager.props,
data: swappedData,
});
manager.processDataUpdate();

// Without stable keys we cannot tell positions apart safely, so we leave
// the cached measurements alone (matching the pre-fix behavior).
for (let i = 0; i < swappedData.length; i++) {
expect(manager.getLayout(i).isHeightMeasured).toBe(true);
expect(manager.getLayout(i).minHeight).toBe(200);
}
});
});
});
65 changes: 65 additions & 0 deletions src/recyclerview/RecyclerViewManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ export class RecyclerViewManager<T> {
private _isDisposed = false;
private _isLayoutManagerDirty = false;
private _animationOptimizationsEnabled = false;
/**
* Snapshot of `keyExtractor(item, index)` results from the last
* processDataUpdate call, indexed by position. Used to detect when the
* item at a given index has been replaced by a different one on an
* in-place data swap so we can invalidate that index's stale measured
* height/minHeight and let it be remeasured against the new content.
* See `invalidateChangedLayouts`.
*/
private prevDataKeys: string[] = [];

public firstItemOffset = 0;
public ignoreScrollEvents = false;
Expand Down Expand Up @@ -301,12 +310,68 @@ export class RecyclerViewManager<T> {

processDataUpdate() {
if (this.hasLayout()) {
this.invalidateChangedLayouts();
this.modifyChildrenLayout([], this.propsRef.data?.length ?? 0);
if (this.hasRenderedProgressively && !this.recomputeEngagedIndices()) {
// recomputeEngagedIndices will update the render stack if there are any changes in the engaged indices.
// It's important to update render stack so that elements are assgined right keys incase items were deleted.
this.updateRenderStack(this.engagedIndicesTracker.getEngagedIndices());
}
} else {
this.recordCurrentDataKeys();
}
}

/**
* Detects per-index identity changes between the previous and current data
* arrays (using keyExtractor) and invalidates the cached measured height
* for indices whose item changed.
*
* Without this, an in-place data swap (e.g. category/filter change) leaves
* the LayoutManager believing every index is still measured at its previous
* height. In a multi-column grid this leaks the previous row's tallest-item
* constraint onto the new content: the ViewHolder applies the stale
* `minHeight`, and the row's `y` for the next row is computed against the
* stale height — so a taller new card overflows into the row below.
*
* Clearing `isHeightMeasured` ensures the next `modifyLayout` call sees
* the index as needing a fresh recompute via
* `computeEstimatesAndMinMaxChangedLayout`, and clearing `minHeight` stops
* the stale constraint from being applied in the next render before the
* first measurement arrives.
*
* No-op when `keyExtractor` is not provided (we cannot safely diff
* positions without stable keys).
*/
private invalidateChangedLayouts(): void {
if (!this.hasStableDataKeys() || !this.layoutManager) {
this.recordCurrentDataKeys();
return;
}
const newLength = this.getDataLength();
const layoutCount = this.layoutManager.getLayoutCount();
const compareLength = Math.min(layoutCount, newLength);
for (let i = 0; i < compareLength; i++) {
const oldKey = this.prevDataKeys[i];
const newKey = this.getDataKey(i);
if (oldKey !== undefined && oldKey !== newKey) {
const layout = this.layoutManager.getLayout(i);
layout.isHeightMeasured = false;
layout.minHeight = undefined;
}
}
this.recordCurrentDataKeys();
}

private recordCurrentDataKeys(): void {
if (!this.hasStableDataKeys()) {
this.prevDataKeys.length = 0;
return;
}
const newLength = this.getDataLength();
this.prevDataKeys.length = newLength;
for (let i = 0; i < newLength; i++) {
this.prevDataKeys[i] = this.getDataKey(i);
}
}

Expand Down
Loading