Skip to content

Commit a009f1d

Browse files
authored
Refactor virtualizer persisted keys (#3394)
1 parent c33baef commit a009f1d

File tree

9 files changed

+184
-96
lines changed

9 files changed

+184
-96
lines changed

packages/@react-aria/grid/src/useGridCell.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {DOMAttributes, FocusableElement, Node as RSNode} from '@react-types/shared';
13+
import {DOMAttributes, FocusableElement} from '@react-types/shared';
1414
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
15-
import {GridCollection} from '@react-types/grid';
15+
import {GridCollection, GridNode} from '@react-types/grid';
1616
import {gridMap} from './utils';
1717
import {GridState} from '@react-stately/grid';
1818
import {isFocusVisible} from '@react-aria/interactions';
@@ -23,7 +23,7 @@ import {useSelectableItem} from '@react-aria/selection';
2323

2424
export interface GridCellProps {
2525
/** An object representing the grid cell. Contains all the relevant information that makes up the grid cell. */
26-
node: RSNode<unknown>,
26+
node: GridNode<unknown>,
2727
/** Whether the grid cell is contained in a virtual scroller. */
2828
isVirtualized?: boolean,
2929
/** Whether the cell or its first focusable child element should be focused when the grid cell is focused. */
@@ -230,7 +230,7 @@ export function useGridCell<T, C extends GridCollection<T>>(props: GridCellProps
230230
});
231231

232232
if (isVirtualized) {
233-
gridCellProps['aria-colindex'] = node.index + 1; // aria-colindex is 1-based
233+
gridCellProps['aria-colindex'] = (node.colIndex ?? node.index) + 1; // aria-colindex is 1-based
234234
}
235235

236236
return {

packages/@react-aria/virtualizer/src/Virtualizer.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,8 @@ export function useVirtualizer<T extends object, V, W>(props: VirtualizerOptions
145145
lastFocusedKey.current = focusedKey;
146146
}, [focusedKey, virtualizer.visibleRect.height, virtualizer, lastFocusedKey, scrollToItem]);
147147

148-
// Tell the virtualizer to persist the focusedKey and prevent it from being reused.
149-
// This is to prevent the FocusRing from setting focus to the ListView when a
150-
// focused ListViewItem is scrolled out of the Virtualizer visible rectangle.
151-
let focusedKeySet = useMemo((): Set<Key> => (
152-
focusedKey ? new Set([focusedKey]) : new Set()
153-
), [focusedKey]);
154-
155-
virtualizer.persistedKeys = focusedKeySet;
148+
// Persist the focusedKey and prevent it from being removed from the DOM when scrolled out of view.
149+
virtualizer.persistedKeys = useMemo(() => focusedKey ? new Set([focusedKey]) : new Set(), [focusedKey]);
156150

157151
let onFocus = useCallback((e: FocusEvent) => {
158152
// If the focused item is scrolled out of view and is not in the DOM, the collection

packages/@react-spectrum/table/test/Table.test.js

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ for (let i = 1; i <= 100; i++) {
6767
manyItems.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i, baz: 'Baz ' + i});
6868
}
6969

70+
let manyColumns = [];
71+
for (let i = 1; i <= 100; i++) {
72+
manyColumns.push({id: i, name: 'Column ' + i});
73+
}
74+
7075
function ExampleSortTable() {
7176
let [sortDescriptor, setSortDescriptor] = React.useState({column: 'bar', direction: 'ascending'});
7277

@@ -816,6 +821,23 @@ describe('TableView', function () {
816821
</TableView>
817822
);
818823

824+
let renderManyColumns = () => render(
825+
<TableView aria-label="Table" selectionMode="multiple">
826+
<TableHeader columns={manyColumns}>
827+
{column =>
828+
<Column>{column.name}</Column>
829+
}
830+
</TableHeader>
831+
<TableBody items={manyItems}>
832+
{item =>
833+
(<Row key={item.foo}>
834+
{key => <Cell>{item.foo + ' ' + key}</Cell>}
835+
</Row>)
836+
}
837+
</TableBody>
838+
</TableView>
839+
);
840+
819841
let focusCell = (tree, text) => act(() => getCell(tree, text).focus());
820842
let moveFocus = (key, opts = {}) => {fireEvent.keyDown(document.activeElement, {key, ...opts});};
821843

@@ -1544,27 +1566,38 @@ describe('TableView', function () {
15441566
});
15451567

15461568
it('should scroll to a cell when it is focused off screen', function () {
1547-
let tree = renderMany();
1569+
let tree = renderManyColumns();
15481570
let body = tree.getByRole('grid').childNodes[1];
15491571

1550-
let cell = getCell(tree, 'Baz 5');
1572+
let cell = getCell(tree, 'Foo 5 5');
15511573
act(() => cell.focus());
15521574
expect(document.activeElement).toBe(cell);
15531575
expect(body.scrollTop).toBe(0);
15541576

15551577
// When scrolling the focused item out of view, focus should remaind on the item,
15561578
// virtualizer keeps focused items from being reused
15571579
body.scrollTop = 1000;
1580+
body.scrollLeft = 1000;
15581581
fireEvent.scroll(body);
15591582

15601583
expect(body.scrollTop).toBe(1000);
15611584
expect(document.activeElement).toBe(cell);
1562-
expect(tree.queryByText('Baz 5')).toBe(cell.firstElementChild);
1585+
expect(tree.queryByText('Foo 5 5')).toBe(cell.firstElementChild);
1586+
1587+
// Ensure we have the correct sticky cells in the right order.
1588+
let row = cell.closest('[role=row]');
1589+
let cells = within(row).getAllByRole(role => role === 'gridcell' || role === 'rowheader');
1590+
expect(cells).toHaveLength(18);
1591+
expect(cells[0]).toHaveAttribute('aria-colindex', '1'); // checkbox
1592+
expect(cells[1]).toHaveAttribute('aria-colindex', '2'); // rowheader
1593+
expect(cells[2]).toHaveAttribute('aria-colindex', '6'); // persisted
1594+
expect(cells[2]).toBe(cell);
1595+
expect(cells[3]).toHaveAttribute('aria-colindex', '14'); // first visible
15631596

15641597
// Moving focus should scroll the new focused item into view
15651598
moveFocus('ArrowLeft');
15661599
expect(body.scrollTop).toBe(164);
1567-
expect(document.activeElement).toBe(getCell(tree, 'Bar 5'));
1600+
expect(document.activeElement).toBe(getCell(tree, 'Foo 5 4'));
15681601
});
15691602

15701603
it('should not scroll when a column header receives focus', function () {

packages/@react-stately/layout/src/ListLayout.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,6 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
109109
res.push(node.header);
110110
}
111111

112-
if (node.children) {
113-
addNodes(node.children);
114-
}
115-
} else if (this.virtualizer.isPersistedKey(node)) {
116-
res.push(node.layoutInfo);
117-
118112
if (node.children) {
119113
addNodes(node.children);
120114
}
@@ -127,7 +121,7 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
127121
}
128122

129123
isVisible(node: LayoutNode, rect: Rect) {
130-
return node.layoutInfo.rect.intersects(rect) || node.layoutInfo.isSticky;
124+
return node.layoutInfo.rect.intersects(rect) || node.layoutInfo.isSticky || this.virtualizer.isPersistedKey(node.layoutInfo.key);
131125
}
132126

133127
validate(invalidationContext: InvalidationContext<Node<T>, unknown>) {

packages/@react-stately/layout/src/TableLayout.ts

Lines changed: 84 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export class TableLayout<T> extends ListLayout<T> {
2424
stickyColumnIndices: number[];
2525
wasLoading = false;
2626
isLoading = false;
27+
lastPersistedKeys: Set<Key> = null;
28+
persistedIndices: Map<Key, number[]> = new Map();
2729

2830
constructor(options: ListLayoutOptions<T>) {
2931
super(options);
@@ -50,6 +52,7 @@ export class TableLayout<T> extends ListLayout<T> {
5052
let header = this.buildHeader();
5153
let body = this.buildBody(0);
5254
this.stickyColumnIndices = this.collection.columns.filter(c => c.props.isSelectionCell || this.collection.rowHeaderColumnKeys.has(c.key)).map(c => c.index);
55+
this.lastPersistedKeys = null;
5356

5457
body.layoutInfo.rect.width = Math.max(header.layoutInfo.rect.width, body.layoutInfo.rect.width);
5558
this.contentSize = new Size(body.layoutInfo.rect.width, body.layoutInfo.rect.maxY);
@@ -125,8 +128,9 @@ export class TableLayout<T> extends ListLayout<T> {
125128
// used to get the column widths when rendering to the DOM
126129
getColumnWidth_(node: GridNode<T>) {
127130
let colspan = node.colspan ?? 1;
131+
let colIndex = node.colIndex ?? node.index;
128132
let width = 0;
129-
for (let i = node.index; i < node.index + colspan; i++) {
133+
for (let i = colIndex; i < colIndex + colspan; i++) {
130134
let column = this.collection.columns[i];
131135
width += this.getColumnWidth(column.key);
132136
}
@@ -274,6 +278,7 @@ export class TableLayout<T> extends ListLayout<T> {
274278
getVisibleLayoutInfos(rect: Rect) {
275279
let res: LayoutInfo[] = [];
276280

281+
this.buildPersistedIndices();
277282
for (let node of this.rootNodes) {
278283
res.push(node.layoutInfo);
279284
this.addVisibleLayoutInfos(res, node, rect);
@@ -298,24 +303,35 @@ export class TableLayout<T> extends ListLayout<T> {
298303
case 'rowgroup': {
299304
let firstVisibleRow = this.binarySearch(node.children, rect.topLeft, 'y');
300305
let lastVisibleRow = this.binarySearch(node.children, rect.bottomRight, 'y');
301-
// Check to see if a persisted key exists before the visible rows
302-
// This is for keeping focus on a row that scrolls out of view
303-
for (let h = 0; h < firstVisibleRow; h++) {
304-
if (this.virtualizer.isPersistedKey(node.children[h])) {
305-
res.push(node.children[h].layoutInfo);
306-
this.addVisibleLayoutInfos(res, node.children[h], rect);
307-
}
306+
307+
// Add persisted rows before the visible rows.
308+
let persistedRowIndices = this.persistedIndices.get(node.layoutInfo.key);
309+
let persistIndex = 0;
310+
while (
311+
persistedRowIndices &&
312+
persistIndex < persistedRowIndices.length &&
313+
persistedRowIndices[persistIndex] < firstVisibleRow
314+
) {
315+
let idx = persistedRowIndices[persistIndex];
316+
res.push(node.children[idx].layoutInfo);
317+
this.addVisibleLayoutInfos(res, node.children[idx], rect);
318+
persistIndex++;
308319
}
320+
309321
for (let i = firstVisibleRow; i <= lastVisibleRow; i++) {
322+
// Skip persisted rows that overlap with visible cells.
323+
while (persistedRowIndices && persistIndex < persistedRowIndices.length && persistedRowIndices[persistIndex] < i) {
324+
persistIndex++;
325+
}
326+
310327
res.push(node.children[i].layoutInfo);
311328
this.addVisibleLayoutInfos(res, node.children[i], rect);
312329
}
313-
// Check to see if a persisted key exists after the visible rows
314-
for (let j = lastVisibleRow + 1; j < node.children.length; j++) {
315-
if (this.virtualizer.isPersistedKey(node.children[j])) {
316-
res.push(node.children[j].layoutInfo);
317-
this.addVisibleLayoutInfos(res, node.children[j], rect);
318-
}
330+
331+
// Add persisted rows after the visible rows.
332+
while (persistedRowIndices && persistIndex < persistedRowIndices.length) {
333+
let idx = persistedRowIndices[persistIndex++];
334+
res.push(node.children[idx].layoutInfo);
319335
}
320336
break;
321337
}
@@ -324,35 +340,27 @@ export class TableLayout<T> extends ListLayout<T> {
324340
let firstVisibleCell = this.binarySearch(node.children, rect.topLeft, 'x');
325341
let lastVisibleCell = this.binarySearch(node.children, rect.topRight, 'x');
326342
let stickyIndex = 0;
327-
// Check to see if a persisted key exists before the visible cells
328-
// This is for keeping focus on a cell that scrolls out of view
329-
for (let h = 0; h < firstVisibleCell; h++) {
330-
if (this.virtualizer.isPersistedKey(node.children[h])) {
331-
res.push(node.children[h].layoutInfo);
332-
}
343+
344+
// Add persisted/sticky cells before the visible cells.
345+
let persistedCellIndices = this.persistedIndices.get(node.layoutInfo.key) || this.stickyColumnIndices;
346+
while (stickyIndex < persistedCellIndices.length && persistedCellIndices[stickyIndex] < firstVisibleCell) {
347+
let idx = persistedCellIndices[stickyIndex];
348+
res.push(node.children[idx].layoutInfo);
349+
stickyIndex++;
333350
}
351+
334352
for (let i = firstVisibleCell; i <= lastVisibleCell; i++) {
335-
// Sticky columns and row headers are always in the DOM. Interleave these
336-
// with the visible range so that they are in the right order.
337-
if (stickyIndex < this.stickyColumnIndices.length) {
338-
let idx = this.stickyColumnIndices[stickyIndex];
339-
while (idx < i) {
340-
res.push(node.children[idx].layoutInfo);
341-
idx = this.stickyColumnIndices[stickyIndex++];
342-
}
353+
// Skip sticky cells that overlap with visible cells.
354+
while (stickyIndex < persistedCellIndices.length && persistedCellIndices[stickyIndex] < i) {
355+
stickyIndex++;
343356
}
344357

345358
res.push(node.children[i].layoutInfo);
346359
}
347-
// Check to see if a persisted key exists after the visible cells
348-
for (let j = lastVisibleCell; j < node.children.length; j++) {
349-
if (this.virtualizer.isPersistedKey(node.children[j])) {
350-
res.push(node.children[j].layoutInfo);
351-
}
352-
}
353360

354-
while (stickyIndex < this.stickyColumnIndices.length) {
355-
let idx = this.stickyColumnIndices[stickyIndex++];
361+
// Add any remaining sticky cells after the visible cells.
362+
while (stickyIndex < persistedCellIndices.length) {
363+
let idx = persistedCellIndices[stickyIndex++];
356364
res.push(node.children[idx].layoutInfo);
357365
}
358366
break;
@@ -381,6 +389,46 @@ export class TableLayout<T> extends ListLayout<T> {
381389
return Math.max(0, Math.min(items.length - 1, low));
382390
}
383391

392+
buildPersistedIndices() {
393+
if (this.virtualizer.persistedKeys === this.lastPersistedKeys) {
394+
return;
395+
}
396+
397+
this.lastPersistedKeys = this.virtualizer.persistedKeys;
398+
this.persistedIndices.clear();
399+
400+
// Build a map of parentKey => indices of children to persist.
401+
for (let key of this.virtualizer.persistedKeys) {
402+
let layoutInfo = this.layoutInfos.get(key);
403+
404+
// Walk up ancestors so parents are also persisted if children are.
405+
while (layoutInfo && layoutInfo.parentKey) {
406+
let collectionNode = this.collection.getItem(layoutInfo.key);
407+
let indices = this.persistedIndices.get(layoutInfo.parentKey);
408+
if (!indices) {
409+
// stickyColumnIndices are always persisted along with any cells from persistedKeys.
410+
indices = collectionNode.type === 'cell' ? [...this.stickyColumnIndices] : [];
411+
this.persistedIndices.set(layoutInfo.parentKey, indices);
412+
}
413+
414+
let index = collectionNode.index;
415+
if (layoutInfo.parentKey === 'body') {
416+
index -= this.collection.headerRows.length;
417+
}
418+
419+
if (!indices.includes(index)) {
420+
indices.push(index);
421+
}
422+
423+
layoutInfo = this.layoutInfos.get(layoutInfo.parentKey);
424+
}
425+
}
426+
427+
for (let indices of this.persistedIndices.values()) {
428+
indices.sort((a, b) => a - b);
429+
}
430+
}
431+
384432
getInitialLayoutInfo(layoutInfo: LayoutInfo) {
385433
let res = super.getInitialLayoutInfo(layoutInfo);
386434

packages/@react-stately/table/src/TableCollection.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ interface GridCollectionOptions {
2020
const ROW_HEADER_COLUMN_KEY = 'row-header-column-' + Math.random().toString(36).slice(2);
2121

2222
function buildHeaderRows<T>(keyMap: Map<Key, GridNode<T>>, columnNodes: GridNode<T>[]): GridNode<T>[] {
23-
let columns = [];
23+
let columns: GridNode<T>[][] = [];
2424
let seen = new Map();
2525
for (let column of columnNodes) {
2626
let parentKey = column.parentKey;
@@ -104,7 +104,7 @@ function buildHeaderRows<T>(keyMap: Map<Key, GridNode<T>>, columnNodes: GridNode
104104
}
105105

106106
item.level = i;
107-
item.index = colIndex;
107+
item.colIndex = colIndex;
108108
row.push(item);
109109
}
110110

0 commit comments

Comments
 (0)