Skip to content

Commit 2dd34e0

Browse files
fix(concurrency): ensure panel stays closed after blur (#829)
Co-authored-by: François Chalifour <[email protected]>
1 parent e080a28 commit 2dd34e0

12 files changed

+410
-75
lines changed

bundlesize.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
"files": [
33
{
44
"path": "packages/autocomplete-core/dist/umd/index.production.js",
5-
"maxSize": "5.75 kB"
5+
"maxSize": "6 kB"
66
},
77
{
88
"path": "packages/autocomplete-js/dist/umd/index.production.js",
9-
"maxSize": "16.25 kB"
9+
"maxSize": "16.5 kB"
1010
},
1111
{
1212
"path": "packages/autocomplete-preset-algolia/dist/umd/index.production.js",

packages/autocomplete-core/src/__tests__/concurrency.test.ts

Lines changed: 265 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,25 @@
11
import userEvent from '@testing-library/user-event';
22

33
import { AutocompleteState } from '..';
4-
import { createSource, defer } from '../../../../test/utils';
4+
import { createPlayground, createSource, defer } from '../../../../test/utils';
55
import { createAutocomplete } from '../createAutocomplete';
66

77
type Item = {
88
label: string;
99
};
1010

11+
beforeEach(() => {
12+
document.body.innerHTML = '';
13+
});
14+
1115
describe('concurrency', () => {
1216
test('resolves the responses in order from getSources', async () => {
13-
// These delays make the second query come back after the third one.
14-
const sourcesDelays = [100, 150, 200];
15-
const itemsDelays = [0, 150, 0];
16-
let deferSourcesCount = -1;
17-
let deferItemsCount = -1;
18-
19-
const getSources = ({ query }) => {
20-
deferSourcesCount++;
21-
22-
return defer(() => {
23-
return [
24-
createSource({
25-
getItems() {
26-
deferItemsCount++;
27-
28-
return defer(
29-
() => [{ label: query }],
30-
itemsDelays[deferItemsCount]
31-
);
32-
},
33-
}),
34-
];
35-
}, sourcesDelays[deferSourcesCount]);
36-
};
17+
const { timeout, delayedGetSources: getSources } = createDelayedGetSources({
18+
// These delays make the second query come back after the third one.
19+
sources: [100, 150, 200],
20+
items: [0, 150, 0],
21+
});
22+
3723
const onStateChange = jest.fn();
3824
const autocomplete = createAutocomplete({ getSources, onStateChange });
3925
const { onChange } = autocomplete.getInputProps({ inputElement: null });
@@ -45,10 +31,6 @@ describe('concurrency', () => {
4531
userEvent.type(input, 'b');
4632
userEvent.type(input, 'c');
4733

48-
const timeout = Math.max(
49-
...sourcesDelays.map((delay, index) => delay + itemsDelays[index])
50-
);
51-
5234
await defer(() => {}, timeout);
5335

5436
let stateHistory: Array<
@@ -91,4 +73,258 @@ describe('concurrency', () => {
9173

9274
document.body.removeChild(input);
9375
});
76+
77+
describe('closing the panel with pending requests', () => {
78+
describe('without debug mode', () => {
79+
test('keeps the panel closed on Escape', async () => {
80+
const onStateChange = jest.fn();
81+
const { timeout, delayedGetSources } = createDelayedGetSources({
82+
sources: [100, 200],
83+
});
84+
const getSources = jest.fn(delayedGetSources);
85+
86+
const { inputElement } = createPlayground(createAutocomplete, {
87+
onStateChange,
88+
getSources,
89+
});
90+
91+
userEvent.type(inputElement, 'ab{esc}');
92+
93+
await defer(() => {}, timeout);
94+
95+
expect(onStateChange).toHaveBeenLastCalledWith(
96+
expect.objectContaining({
97+
state: expect.objectContaining({
98+
isOpen: false,
99+
status: 'idle',
100+
}),
101+
})
102+
);
103+
expect(getSources).toHaveBeenCalledTimes(2);
104+
});
105+
106+
test('keeps the panel closed on blur', async () => {
107+
const onStateChange = jest.fn();
108+
const { timeout, delayedGetSources } = createDelayedGetSources({
109+
sources: [100, 200],
110+
});
111+
const getSources = jest.fn(delayedGetSources);
112+
113+
const { inputElement } = createPlayground(createAutocomplete, {
114+
onStateChange,
115+
getSources,
116+
});
117+
118+
userEvent.type(inputElement, 'a{enter}');
119+
120+
await defer(() => {}, timeout);
121+
122+
expect(onStateChange).toHaveBeenLastCalledWith(
123+
expect.objectContaining({
124+
state: expect.objectContaining({
125+
isOpen: false,
126+
status: 'idle',
127+
}),
128+
})
129+
);
130+
expect(getSources).toHaveBeenCalledTimes(1);
131+
});
132+
133+
test('keeps the panel closed on touchstart blur', async () => {
134+
const onStateChange = jest.fn();
135+
const { timeout, delayedGetSources } = createDelayedGetSources({
136+
sources: [100, 200],
137+
});
138+
const getSources = jest.fn(delayedGetSources);
139+
140+
const {
141+
getEnvironmentProps,
142+
inputElement,
143+
formElement,
144+
} = createPlayground(createAutocomplete, {
145+
onStateChange,
146+
getSources,
147+
});
148+
149+
const panelElement = document.createElement('div');
150+
151+
const { onTouchStart } = getEnvironmentProps({
152+
inputElement,
153+
formElement,
154+
panelElement,
155+
});
156+
window.addEventListener('touchstart', onTouchStart);
157+
158+
userEvent.type(inputElement, 'a');
159+
const customEvent = new CustomEvent('touchstart', { bubbles: true });
160+
window.document.dispatchEvent(customEvent);
161+
162+
await defer(() => {}, timeout);
163+
164+
expect(onStateChange).toHaveBeenLastCalledWith(
165+
expect.objectContaining({
166+
state: expect.objectContaining({
167+
isOpen: false,
168+
status: 'idle',
169+
}),
170+
})
171+
);
172+
expect(getSources).toHaveBeenCalledTimes(1);
173+
174+
window.removeEventListener('touchstart', onTouchStart);
175+
});
176+
});
177+
178+
describe('with debug mode', () => {
179+
const delay = 300;
180+
181+
test('keeps the panel closed on Escape', async () => {
182+
const onStateChange = jest.fn();
183+
const getSources = jest.fn(() => {
184+
return defer(() => {
185+
return [
186+
createSource({
187+
getItems: () => [{ label: '1' }, { label: '2' }],
188+
}),
189+
];
190+
}, delay);
191+
});
192+
const { inputElement } = createPlayground(createAutocomplete, {
193+
debug: true,
194+
onStateChange,
195+
getSources,
196+
});
197+
198+
userEvent.type(inputElement, 'a{esc}');
199+
200+
await defer(() => {}, delay);
201+
202+
expect(onStateChange).toHaveBeenLastCalledWith(
203+
expect.objectContaining({
204+
state: expect.objectContaining({
205+
isOpen: false,
206+
status: 'idle',
207+
}),
208+
})
209+
);
210+
expect(getSources).toHaveBeenCalledTimes(1);
211+
});
212+
213+
test('keeps the panel open on blur', async () => {
214+
const onStateChange = jest.fn();
215+
const getSources = jest.fn(() => {
216+
return defer(() => {
217+
return [
218+
createSource({
219+
getItems: () => [{ label: '1' }, { label: '2' }],
220+
}),
221+
];
222+
}, delay);
223+
});
224+
const { inputElement } = createPlayground(createAutocomplete, {
225+
debug: true,
226+
onStateChange,
227+
getSources,
228+
});
229+
230+
userEvent.type(inputElement, 'a{enter}');
231+
232+
await defer(() => {}, delay);
233+
234+
expect(onStateChange).toHaveBeenLastCalledWith(
235+
expect.objectContaining({
236+
state: expect.objectContaining({
237+
isOpen: true,
238+
status: 'idle',
239+
}),
240+
})
241+
);
242+
expect(getSources).toHaveBeenCalledTimes(1);
243+
});
244+
245+
test('keeps the panel open on touchstart blur', async () => {
246+
const onStateChange = jest.fn();
247+
const getSources = jest.fn(() => {
248+
return defer(() => {
249+
return [
250+
createSource({
251+
getItems: () => [{ label: '1' }, { label: '2' }],
252+
}),
253+
];
254+
}, delay);
255+
});
256+
const {
257+
getEnvironmentProps,
258+
inputElement,
259+
formElement,
260+
} = createPlayground(createAutocomplete, {
261+
debug: true,
262+
onStateChange,
263+
getSources,
264+
});
265+
266+
const panelElement = document.createElement('div');
267+
268+
const { onTouchStart } = getEnvironmentProps({
269+
inputElement,
270+
formElement,
271+
panelElement,
272+
});
273+
window.addEventListener('touchstart', onTouchStart);
274+
275+
userEvent.type(inputElement, 'a');
276+
const customEvent = new CustomEvent('touchstart', { bubbles: true });
277+
window.document.dispatchEvent(customEvent);
278+
279+
await defer(() => {}, delay);
280+
281+
expect(onStateChange).toHaveBeenLastCalledWith(
282+
expect.objectContaining({
283+
state: expect.objectContaining({
284+
isOpen: true,
285+
status: 'idle',
286+
}),
287+
})
288+
);
289+
expect(getSources).toHaveBeenCalledTimes(1);
290+
291+
window.removeEventListener('touchstart', onTouchStart);
292+
});
293+
});
294+
});
94295
});
296+
297+
function createDelayedGetSources(delays: {
298+
sources: number[];
299+
items?: number[];
300+
}) {
301+
let deferSourcesCount = -1;
302+
let deferItemsCount = -1;
303+
304+
const itemsDelays = delays.items || delays.sources.map(() => 0);
305+
306+
const timeout = Math.max(
307+
...delays.sources.map((delay, index) => delay + itemsDelays[index])
308+
);
309+
310+
function delayedGetSources({ query }) {
311+
deferSourcesCount++;
312+
313+
return defer(() => {
314+
return [
315+
createSource({
316+
getItems() {
317+
deferItemsCount++;
318+
319+
return defer(
320+
() => [{ label: query }],
321+
itemsDelays[deferItemsCount]
322+
);
323+
},
324+
}),
325+
];
326+
}, delays.sources[deferSourcesCount]);
327+
}
328+
329+
return { timeout, delayedGetSources };
330+
}

packages/autocomplete-core/src/__tests__/getEnvironmentProps.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('getEnvironmentProps', () => {
3030
});
3131

3232
describe('onTouchStart', () => {
33-
test('is a noop when panel is not open', () => {
33+
test('is a noop when panel is not open and status is idle', () => {
3434
const onStateChange = jest.fn();
3535
const {
3636
getEnvironmentProps,

packages/autocomplete-core/src/__tests__/getInputProps.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1894,7 +1894,7 @@ describe('getInputProps', () => {
18941894
});
18951895

18961896
describe('onBlur', () => {
1897-
test('resets activeItemId and isOpen', () => {
1897+
test('resets activeItemId and isOpen', async () => {
18981898
const onStateChange = jest.fn();
18991899
const { inputElement } = createPlayground(createAutocomplete, {
19001900
onStateChange,
@@ -1905,6 +1905,8 @@ describe('getInputProps', () => {
19051905
inputElement.focus();
19061906
inputElement.blur();
19071907

1908+
await runAllMicroTasks();
1909+
19081910
expect(onStateChange).toHaveBeenLastCalledWith(
19091911
expect.objectContaining({
19101912
state: expect.objectContaining({

packages/autocomplete-core/src/__tests__/getSources.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {
88
import { createAutocomplete } from '../createAutocomplete';
99
import * as handlers from '../onInput';
1010

11+
beforeEach(() => {
12+
document.body.innerHTML = '';
13+
});
14+
1115
describe('getSources', () => {
1216
test('gets calls on input', () => {
1317
const getSources = jest.fn((..._args: any[]) => {
@@ -140,7 +144,13 @@ describe('getSources', () => {
140144

141145
const { inputElement } = createPlayground(createAutocomplete, {
142146
getSources() {
143-
return [createSource({ sourceId: 'source1', getItems: () => {} })];
147+
return [
148+
createSource({
149+
sourceId: 'source1',
150+
// @ts-expect-error
151+
getItems: () => {},
152+
}),
153+
];
144154
},
145155
});
146156

packages/autocomplete-core/src/createStore.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,6 @@ export function createStore<TItem extends BaseItem>(
3535

3636
onStoreStateChange({ state, prevState });
3737
},
38+
shouldSkipPendingUpdate: false,
3839
};
3940
}

0 commit comments

Comments
 (0)