Skip to content

Commit be7d233

Browse files
committed
fix rowindex calculation when filtering async s2 combobox
the document didnt seem to be both updating its _minInvalidChildIndex nor updating its child indicies properly when the combobox list was filtered async (aka the collection got new items added and removed via insertBefore and/or removeChild. Additionally, we didnt see to ever call updateChildIndices on the Document other than the first time the collection loaded
1 parent a0b9e5d commit be7d233

File tree

6 files changed

+182
-8
lines changed

6 files changed

+182
-8
lines changed

packages/@react-aria/collections/src/Document.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export class BaseNode<T> {
103103
}
104104

105105
private invalidateChildIndices(child: ElementNode<T>): void {
106-
if (this._minInvalidChildIndex == null || child.index < this._minInvalidChildIndex.index) {
106+
if (this._minInvalidChildIndex == null || !this._minInvalidChildIndex.isConnected || child.index < this._minInvalidChildIndex.index) {
107107
this._minInvalidChildIndex = child;
108108
}
109109
}
@@ -154,7 +154,11 @@ export class BaseNode<T> {
154154

155155
newNode.nextSibling = referenceNode;
156156
newNode.previousSibling = referenceNode.previousSibling;
157-
newNode.index = referenceNode.index;
157+
// Ensure that the newNode's index is less than that of the reference node so that
158+
// invalidateChildIndices will properly use the newNode as the _minInvalidChildIndex, thus making sure
159+
// we will properly update the indexes of all sibiling nodes after the newNode. The value here doesn't matter
160+
// since updateChildIndices should calculate the proper indexes.
161+
newNode.index = referenceNode.index - 1;
158162
if (this.firstChild === referenceNode) {
159163
this.firstChild = newNode;
160164
} else if (referenceNode.previousSibling) {
@@ -469,6 +473,12 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
469473
}
470474

471475
// Next, update dirty collection nodes.
476+
// TODO: when updateCollection is called here, shouldn't we be calling this.updateChildIndicies as well? Theoretically it should only update
477+
// nodes from _minInvalidChildIndex onwards so the increase in dirtyNodes should be minimal.
478+
// Is element.updateNode supposed to handle that (it currently assumes the index stored on the node is correct already).
479+
// At the moment, without this call to updateChildIndicies, filtering an async combobox doesn't actually update the index values of the
480+
// updated collection...
481+
this.updateChildIndices();
472482
for (let element of this.dirtyNodes) {
473483
if (element instanceof ElementNode) {
474484
if (element.isConnected && !element.isHidden) {

packages/@react-aria/interactions/src/usePress.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ export function usePress(props: PressHookProps): PressResult {
372372
if (isDisabled) {
373373
e.preventDefault();
374374
}
375-
375+
376376
// If triggered from a screen reader or by using element.click(),
377377
// trigger as if it were a keyboard click.
378378
if (!state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) {
@@ -820,7 +820,7 @@ export function usePress(props: PressHookProps): PressResult {
820820
// Only apply touch-action if not already set by another CSS rule.
821821
let style = getOwnerWindow(element).getComputedStyle(element);
822822
if (style.touchAction === 'auto') {
823-
// touchAction: 'manipulation' is supposed to be equivalent, but in
823+
// touchAction: 'manipulation' is supposed to be equivalent, but in
824824
// Safari it causes onPointerCancel not to fire on scroll.
825825
// https://bugs.webkit.org/show_bug.cgi?id=240917
826826
(element as HTMLElement).style.touchAction = 'pan-x pan-y pinch-zoom';

packages/@react-spectrum/s2/stories/Picker.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ export const AsyncPickerStory = {
261261
render: AsyncPicker,
262262
args: {
263263
...Example.args,
264+
label: 'Star Wars Character',
264265
delay: 50
265266
},
266267
name: 'Async loading picker',

packages/@react-spectrum/s2/test/Combobox.test.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ import userEvent from '@testing-library/user-event';
2121
describe('Combobox', () => {
2222
let user;
2323
let testUtilUser = new User();
24+
function DynamicCombobox(props) {
25+
let {items, loadingState, onLoadMore, ...otherProps} = props;
26+
return (
27+
<ComboBox
28+
{...otherProps}
29+
label="Test combobox"
30+
items={items}
31+
loadingState={loadingState}
32+
onLoadMore={onLoadMore}>
33+
{(item: any) => <ComboBoxItem id={item.name} textValue={item.name}>{item.name}</ComboBoxItem>}
34+
</ComboBox>
35+
);
36+
}
2437

2538
beforeAll(function () {
2639
jest.useFakeTimers();
@@ -101,7 +114,7 @@ describe('Combobox', () => {
101114
expect(onLoadMore).toHaveBeenCalledTimes(2);
102115
});
103116

104-
it('should omit the laoder from the count of items', async () => {
117+
it('should omit the loader from the count of items', async () => {
105118
jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'MacIntel');
106119
let tree = render(
107120
<ComboBox label="test" loadingState="loadingMore">
@@ -122,4 +135,31 @@ describe('Combobox', () => {
122135
await user.keyboard('C');
123136
expect(announce).toHaveBeenLastCalledWith('2 options available.');
124137
});
138+
139+
it('should properly calculate the expected row index values even when the content changes', async () => {
140+
let items = [{name: 'Chocolate'}, {name: 'Mint'}, {name: 'Chocolate Chip'}];
141+
let tree = render(<DynamicCombobox items={items} />);
142+
143+
let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container, interactionType: 'mouse'});
144+
await comboboxTester.open();
145+
let options = comboboxTester.options();
146+
for (let [index, option] of options.entries()) {
147+
expect(option).toHaveAttribute('aria-posinset', `${index + 1}`);
148+
}
149+
150+
tree.rerender(<DynamicCombobox items={items} loadingState="filtering" />);
151+
options = comboboxTester.options();
152+
for (let [index, option] of options.entries()) {
153+
expect(option).toHaveAttribute('aria-posinset', `${index + 1}`);
154+
}
155+
156+
// A bit contrived, but essentially testing a combinaiton of insertions/deletions along side some of the old entries remaining
157+
let newItems = [{name: 'Chocolate Mint'}, {name: 'Chocolate'}, {name: 'Chocolate Chip'}, {name: 'Chocolate Chip Cookie Dough'}]
158+
tree.rerender(<DynamicCombobox items={newItems} loadingState="idle" />);
159+
160+
options = comboboxTester.options();
161+
for (let [index, option] of options.entries()) {
162+
expect(option).toHaveAttribute('aria-posinset', `${index + 1}`);
163+
}
164+
});
125165
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {act, render, setupIntersectionObserverMock} from '@react-spectrum/test-utils-internal';
14+
import {Picker, PickerItem} from '../src';
15+
import React from 'react';
16+
import {User} from '@react-aria/test-utils';
17+
18+
describe('Picker', () => {
19+
let testUtilUser = new User();
20+
function DynamicPicker(props) {
21+
let {items, isLoading, onLoadMore, ...otherProps} = props;
22+
return (
23+
<Picker
24+
{...otherProps}
25+
label="Test picker"
26+
items={items}
27+
isLoading={isLoading}
28+
onLoadMore={onLoadMore}>
29+
{(item: any) => <PickerItem id={item.name} textValue={item.name}>{item.name}</PickerItem>}
30+
</Picker>
31+
);
32+
}
33+
34+
beforeAll(function () {
35+
jest.useFakeTimers();
36+
jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100);
37+
jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100);
38+
});
39+
40+
afterEach(() => {
41+
jest.clearAllMocks();
42+
act(() => jest.runAllTimers());
43+
});
44+
45+
afterAll(function () {
46+
jest.restoreAllMocks();
47+
});
48+
49+
it.skip('should only call loadMore whenever intersection is detected', async () => {
50+
let onLoadMore = jest.fn();
51+
let observe = jest.fn();
52+
let observer = setupIntersectionObserverMock({
53+
observe
54+
});
55+
56+
let tree = render(
57+
<Picker label="test" isLoading onLoadMore={onLoadMore}>
58+
<PickerItem>Chocolate</PickerItem>
59+
<PickerItem>Mint</PickerItem>
60+
<PickerItem>Strawberry</PickerItem>
61+
<PickerItem>Vanilla</PickerItem>
62+
<PickerItem>Chocolate Chip Cookie Dough</PickerItem>
63+
</Picker>
64+
);
65+
66+
let selectTester = testUtilUser.createTester('Select', {root: tree.container});
67+
expect(selectTester.listbox).toBeFalsy();
68+
selectTester.setInteractionType('mouse');
69+
await selectTester.open();
70+
71+
expect(onLoadMore).toHaveBeenCalledTimes(0);
72+
let sentinel = tree.getByTestId('loadMoreSentinel');
73+
expect(observe).toHaveBeenLastCalledWith(sentinel);
74+
75+
act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);});
76+
act(() => {jest.runAllTimers();});
77+
78+
tree.rerender(
79+
<Picker label="test" onLoadMore={onLoadMore}>
80+
<PickerItem>Chocolate</PickerItem>
81+
<PickerItem>Mint</PickerItem>
82+
<PickerItem>Strawberry</PickerItem>
83+
<PickerItem>Vanilla</PickerItem>
84+
<PickerItem>Chocolate Chip Cookie Dough</PickerItem>
85+
</Picker>
86+
);
87+
88+
act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);});
89+
act(() => {jest.runAllTimers();});
90+
// Note that if this was using useAsyncList, we'd be shielded from extranous onLoadMore calls but
91+
// we want to leave that to user discretion
92+
expect(onLoadMore).toHaveBeenCalledTimes(2);
93+
});
94+
95+
it.skip('should properly calculate the expected row index values', async () => {
96+
let items = [{name: 'Chocolate'}, {name: 'Mint'}, {name: 'Chocolate Chip'}];
97+
let tree = render(<DynamicPicker items={items} />);
98+
99+
let selectTester = testUtilUser.createTester('Select', {root: tree.container});
100+
await selectTester.open();
101+
let options = selectTester.options();
102+
for (let [index, option] of options.entries()) {
103+
expect(option).toHaveAttribute('aria-posinset', `${index + 1}`);
104+
}
105+
106+
tree.rerender(<DynamicPicker items={items} isLoading />);
107+
options = selectTester.options();
108+
for (let [index, option] of options.entries()) {
109+
expect(option).toHaveAttribute('aria-posinset', `${index + 1}`);
110+
}
111+
112+
let newItems = [...items, {name: 'Chocolate Mint'}, {name: 'Chocolate Chip Cookie Dough'}]
113+
tree.rerender(<DynamicPicker items={newItems} />);
114+
115+
options = selectTester.options();
116+
for (let [index, option] of options.entries()) {
117+
expect(option).toHaveAttribute('aria-posinset', `${index + 1}`);
118+
}
119+
});
120+
});

packages/react-aria-components/test/ListBox.test.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,6 +1365,7 @@ describe('ListBox', () => {
13651365
let onLoadMore = jest.fn();
13661366
let observe = jest.fn();
13671367
afterEach(() => {
1368+
jest.runAllTimers();
13681369
jest.clearAllMocks();
13691370
});
13701371

@@ -1444,16 +1445,16 @@ describe('ListBox', () => {
14441445
clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100);
14451446
});
14461447

1447-
beforeEach(() => {
1448+
afterEach(() => {
14481449
act(() => {jest.runAllTimers();});
14491450
});
14501451

1451-
afterAll(function () {
1452+
afterAll(() => {
14521453
clientWidth.mockReset();
14531454
clientHeight.mockReset();
14541455
});
14551456

1456-
let VirtualizedAsyncListbox = (props) => {
1457+
function VirtualizedAsyncListbox(props) {
14571458
let {items, isLoading, onLoadMore, ...listBoxProps} = props;
14581459
return (
14591460
<Virtualizer
@@ -1499,6 +1500,8 @@ describe('ListBox', () => {
14991500
});
15001501

15011502
// TODO: for some reason this tree renders empty if ran with the above test...
1503+
// Even if the above test doesn't do anything within it, the below tree won't render with content until the above test
1504+
// is fully commented out (aka even the it(...))
15021505
// It thinks that the contextSize is 0 and never updates
15031506
it.skip('should not reserve room for the loader if isLoading is false', () => {
15041507
let tree = render(<VirtualizedAsyncListbox items={items} />);

0 commit comments

Comments
 (0)