Fix flaky react-select crash in PaginatedSelect caused by debounce return value (#25301)

* Fix flaky react-select crash in PaginatedSelect caused by debounce return value

The handleSearch debounced callback was returning the Promise from
loadOptions(). Lodash debounce returns the result of the last invocation
on subsequent calls. When react-select's onInputChange handler received
this Promise, it checked `if (nextValue != null)` — which is truthy for
a Promise — and set its internal inputValue state to the Promise object.
On the next re-render, trimString(promise) crashed with
"str.replace is not a function".

This was a latent bug exposed by the user-event v13 → v14 migration.
In v13, userEvent.type() fired all keystrokes nearly synchronously, so
the 400ms debounce never fired between keystrokes and the wrapper always
returned undefined. In v14, keystrokes are dispatched individually with
proper async handling, giving the debounce timer a chance to fire between
keystrokes — making the crash flaky depending on execution speed.

The fix: don't return the Promise from the debounced callback, so
react-select never mistakes it for a new input value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix flaky react-select crash in PaginatedSelect caused by debounce return value

The handleSearch debounced callback was returning the Promise from
loadOptions(). Lodash debounce returns the result of the last invocation
on subsequent calls. When react-select's onInputChange handler received
this Promise, it checked `if (nextValue != null)` — which is truthy for
a Promise — and set its internal inputValue state to the Promise object.
On the next re-render, trimString(promise) crashed with
"str.replace is not a function".

This was a latent bug exposed by the user-event v13 → v14 migration.
In v13, userEvent.type() fired all keystrokes nearly synchronously, so
the 400ms debounce never fired between keystrokes and the wrapper always
returned undefined. In v14, keystrokes are dispatched individually with
proper async handling, giving the debounce timer a chance to fire between
keystrokes — making the crash flaky depending on execution speed.

The fix: don't return the Promise from the debounced callback, so
react-select never mistakes it for a new input value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dennis Oelkers
2026-03-12 10:21:14 +01:00
committed by GitHub
parent 1ed315c604
commit 1a598f3dbd
2 changed files with 83 additions and 7 deletions

View File

@@ -0,0 +1,80 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import React from 'react';
import { render, screen, act } from 'wrappedTestingLibrary';
import userEvent from '@testing-library/user-event';
import PaginatedSelect from './PaginatedSelect';
const mockOptions = {
list: [
{ label: 'Alpha', value: 'alpha' },
{ label: 'Beta', value: 'beta' },
],
pagination: { page: 1, perPage: 50, query: '' },
total: 2,
};
describe('PaginatedSelect', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should not crash when typing after debounced search fires', async () => {
// The debounced handleSearch callback previously returned the Promise from
// loadOptions. After the debounce fired, lodash's debounce wrapper would
// return that Promise on subsequent calls. react-select treated the
// non-null return value as a new inputValue, setting its internal state to
// a Promise object, which crashed in trimString() with
// "str.replace is not a function".
const onLoadOptions = jest.fn(() => Promise.resolve(mockOptions));
render(
<PaginatedSelect
placeholder="Pick one"
onLoadOptions={onLoadOptions}
onChange={() => {}}
/>,
);
// Wait for initial load
await act(() => jest.runAllTimersAsync());
const input = await screen.findByRole('combobox', { name: 'Pick one' });
// Type first character — starts the 400ms debounce
await userEvent.type(input, 'A', { advanceTimers: jest.advanceTimersByTimeAsync });
// Fire the debounce so loadOptions runs and the debounced wrapper caches
// its Promise return value
await act(() => jest.advanceTimersByTimeAsync(500));
// Type another character — the debounced wrapper now returns the cached
// Promise. Before the fix, react-select would use it as inputValue and
// crash in trimString().
await userEvent.type(input, 'l', { advanceTimers: jest.advanceTimersByTimeAsync });
await act(() => jest.advanceTimersByTimeAsync(500));
// If we got here without throwing, the fix works.
expect(input).toBeInTheDocument();
});
});

View File

@@ -58,14 +58,10 @@ const PaginatedSelect = ({ onLoadOptions, ...rest }: Props) => {
const handleSearch = debounce((newValue, actionMeta) => {
if (actionMeta.action === 'input-change') {
return loadOptions({ ...DEFAULT_PAGINATION, query: newValue });
loadOptions({ ...DEFAULT_PAGINATION, query: newValue });
} else if (actionMeta.action === 'menu-close') {
loadOptions(DEFAULT_PAGINATION);
}
if (actionMeta.action === 'menu-close') {
return loadOptions(DEFAULT_PAGINATION);
}
return Promise.resolve();
}, 400);
const handleLoadMore = debounce(() => {