Skip to content

Commit

Permalink
fix(concurrency): ensure panel stays closed after blur (#829)
Browse files Browse the repository at this point in the history
Co-authored-by: François Chalifour <[email protected]>
  • Loading branch information
sarahdayan and francoischalifour authored Dec 9, 2021
1 parent e080a28 commit 2dd34e0
Show file tree
Hide file tree
Showing 12 changed files with 410 additions and 75 deletions.
4 changes: 2 additions & 2 deletions bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
"files": [
{
"path": "packages/autocomplete-core/dist/umd/index.production.js",
"maxSize": "5.75 kB"
"maxSize": "6 kB"
},
{
"path": "packages/autocomplete-js/dist/umd/index.production.js",
"maxSize": "16.25 kB"
"maxSize": "16.5 kB"
},
{
"path": "packages/autocomplete-preset-algolia/dist/umd/index.production.js",
Expand Down
294 changes: 265 additions & 29 deletions packages/autocomplete-core/src/__tests__/concurrency.test.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,25 @@
import userEvent from '@testing-library/user-event';

import { AutocompleteState } from '..';
import { createSource, defer } from '../../../../test/utils';
import { createPlayground, createSource, defer } from '../../../../test/utils';
import { createAutocomplete } from '../createAutocomplete';

type Item = {
label: string;
};

beforeEach(() => {
document.body.innerHTML = '';
});

describe('concurrency', () => {
test('resolves the responses in order from getSources', async () => {
// These delays make the second query come back after the third one.
const sourcesDelays = [100, 150, 200];
const itemsDelays = [0, 150, 0];
let deferSourcesCount = -1;
let deferItemsCount = -1;

const getSources = ({ query }) => {
deferSourcesCount++;

return defer(() => {
return [
createSource({
getItems() {
deferItemsCount++;

return defer(
() => [{ label: query }],
itemsDelays[deferItemsCount]
);
},
}),
];
}, sourcesDelays[deferSourcesCount]);
};
const { timeout, delayedGetSources: getSources } = createDelayedGetSources({
// These delays make the second query come back after the third one.
sources: [100, 150, 200],
items: [0, 150, 0],
});

const onStateChange = jest.fn();
const autocomplete = createAutocomplete({ getSources, onStateChange });
const { onChange } = autocomplete.getInputProps({ inputElement: null });
Expand All @@ -45,10 +31,6 @@ describe('concurrency', () => {
userEvent.type(input, 'b');
userEvent.type(input, 'c');

const timeout = Math.max(
...sourcesDelays.map((delay, index) => delay + itemsDelays[index])
);

await defer(() => {}, timeout);

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

document.body.removeChild(input);
});

describe('closing the panel with pending requests', () => {
describe('without debug mode', () => {
test('keeps the panel closed on Escape', async () => {
const onStateChange = jest.fn();
const { timeout, delayedGetSources } = createDelayedGetSources({
sources: [100, 200],
});
const getSources = jest.fn(delayedGetSources);

const { inputElement } = createPlayground(createAutocomplete, {
onStateChange,
getSources,
});

userEvent.type(inputElement, 'ab{esc}');

await defer(() => {}, timeout);

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
isOpen: false,
status: 'idle',
}),
})
);
expect(getSources).toHaveBeenCalledTimes(2);
});

test('keeps the panel closed on blur', async () => {
const onStateChange = jest.fn();
const { timeout, delayedGetSources } = createDelayedGetSources({
sources: [100, 200],
});
const getSources = jest.fn(delayedGetSources);

const { inputElement } = createPlayground(createAutocomplete, {
onStateChange,
getSources,
});

userEvent.type(inputElement, 'a{enter}');

await defer(() => {}, timeout);

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
isOpen: false,
status: 'idle',
}),
})
);
expect(getSources).toHaveBeenCalledTimes(1);
});

test('keeps the panel closed on touchstart blur', async () => {
const onStateChange = jest.fn();
const { timeout, delayedGetSources } = createDelayedGetSources({
sources: [100, 200],
});
const getSources = jest.fn(delayedGetSources);

const {
getEnvironmentProps,
inputElement,
formElement,
} = createPlayground(createAutocomplete, {
onStateChange,
getSources,
});

const panelElement = document.createElement('div');

const { onTouchStart } = getEnvironmentProps({
inputElement,
formElement,
panelElement,
});
window.addEventListener('touchstart', onTouchStart);

userEvent.type(inputElement, 'a');
const customEvent = new CustomEvent('touchstart', { bubbles: true });
window.document.dispatchEvent(customEvent);

await defer(() => {}, timeout);

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
isOpen: false,
status: 'idle',
}),
})
);
expect(getSources).toHaveBeenCalledTimes(1);

window.removeEventListener('touchstart', onTouchStart);
});
});

describe('with debug mode', () => {
const delay = 300;

test('keeps the panel closed on Escape', async () => {
const onStateChange = jest.fn();
const getSources = jest.fn(() => {
return defer(() => {
return [
createSource({
getItems: () => [{ label: '1' }, { label: '2' }],
}),
];
}, delay);
});
const { inputElement } = createPlayground(createAutocomplete, {
debug: true,
onStateChange,
getSources,
});

userEvent.type(inputElement, 'a{esc}');

await defer(() => {}, delay);

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
isOpen: false,
status: 'idle',
}),
})
);
expect(getSources).toHaveBeenCalledTimes(1);
});

test('keeps the panel open on blur', async () => {
const onStateChange = jest.fn();
const getSources = jest.fn(() => {
return defer(() => {
return [
createSource({
getItems: () => [{ label: '1' }, { label: '2' }],
}),
];
}, delay);
});
const { inputElement } = createPlayground(createAutocomplete, {
debug: true,
onStateChange,
getSources,
});

userEvent.type(inputElement, 'a{enter}');

await defer(() => {}, delay);

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
isOpen: true,
status: 'idle',
}),
})
);
expect(getSources).toHaveBeenCalledTimes(1);
});

test('keeps the panel open on touchstart blur', async () => {
const onStateChange = jest.fn();
const getSources = jest.fn(() => {
return defer(() => {
return [
createSource({
getItems: () => [{ label: '1' }, { label: '2' }],
}),
];
}, delay);
});
const {
getEnvironmentProps,
inputElement,
formElement,
} = createPlayground(createAutocomplete, {
debug: true,
onStateChange,
getSources,
});

const panelElement = document.createElement('div');

const { onTouchStart } = getEnvironmentProps({
inputElement,
formElement,
panelElement,
});
window.addEventListener('touchstart', onTouchStart);

userEvent.type(inputElement, 'a');
const customEvent = new CustomEvent('touchstart', { bubbles: true });
window.document.dispatchEvent(customEvent);

await defer(() => {}, delay);

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
isOpen: true,
status: 'idle',
}),
})
);
expect(getSources).toHaveBeenCalledTimes(1);

window.removeEventListener('touchstart', onTouchStart);
});
});
});
});

function createDelayedGetSources(delays: {
sources: number[];
items?: number[];
}) {
let deferSourcesCount = -1;
let deferItemsCount = -1;

const itemsDelays = delays.items || delays.sources.map(() => 0);

const timeout = Math.max(
...delays.sources.map((delay, index) => delay + itemsDelays[index])
);

function delayedGetSources({ query }) {
deferSourcesCount++;

return defer(() => {
return [
createSource({
getItems() {
deferItemsCount++;

return defer(
() => [{ label: query }],
itemsDelays[deferItemsCount]
);
},
}),
];
}, delays.sources[deferSourcesCount]);
}

return { timeout, delayedGetSources };
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('getEnvironmentProps', () => {
});

describe('onTouchStart', () => {
test('is a noop when panel is not open', () => {
test('is a noop when panel is not open and status is idle', () => {
const onStateChange = jest.fn();
const {
getEnvironmentProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1894,7 +1894,7 @@ describe('getInputProps', () => {
});

describe('onBlur', () => {
test('resets activeItemId and isOpen', () => {
test('resets activeItemId and isOpen', async () => {
const onStateChange = jest.fn();
const { inputElement } = createPlayground(createAutocomplete, {
onStateChange,
Expand All @@ -1905,6 +1905,8 @@ describe('getInputProps', () => {
inputElement.focus();
inputElement.blur();

await runAllMicroTasks();

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
Expand Down
12 changes: 11 additions & 1 deletion packages/autocomplete-core/src/__tests__/getSources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
import { createAutocomplete } from '../createAutocomplete';
import * as handlers from '../onInput';

beforeEach(() => {
document.body.innerHTML = '';
});

describe('getSources', () => {
test('gets calls on input', () => {
const getSources = jest.fn((..._args: any[]) => {
Expand Down Expand Up @@ -140,7 +144,13 @@ describe('getSources', () => {

const { inputElement } = createPlayground(createAutocomplete, {
getSources() {
return [createSource({ sourceId: 'source1', getItems: () => {} })];
return [
createSource({
sourceId: 'source1',
// @ts-expect-error
getItems: () => {},
}),
];
},
});

Expand Down
1 change: 1 addition & 0 deletions packages/autocomplete-core/src/createStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ export function createStore<TItem extends BaseItem>(

onStoreStateChange({ state, prevState });
},
shouldSkipPendingUpdate: false,
};
}
Loading

0 comments on commit 2dd34e0

Please sign in to comment.