mirror of
https://github.com/Graylog2/graylog2-server.git
synced 2026-03-13 09:32:21 +08:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user