From 1a598f3dbd7cadefd2f330a70da45a731b8be0e0 Mon Sep 17 00:00:00 2001 From: Dennis Oelkers Date: Thu, 12 Mar 2026 10:21:14 +0100 Subject: [PATCH] Fix flaky react-select crash in PaginatedSelect caused by debounce return value (#25301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- .../common/Select/PaginatedSelect.test.tsx | 80 +++++++++++++++++++ .../common/Select/PaginatedSelect.tsx | 10 +-- 2 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 graylog2-web-interface/src/components/common/Select/PaginatedSelect.test.tsx diff --git a/graylog2-web-interface/src/components/common/Select/PaginatedSelect.test.tsx b/graylog2-web-interface/src/components/common/Select/PaginatedSelect.test.tsx new file mode 100644 index 0000000000..7c120ab0f3 --- /dev/null +++ b/graylog2-web-interface/src/components/common/Select/PaginatedSelect.test.tsx @@ -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 + * . + */ +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( + {}} + />, + ); + + // 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(); + }); +}); diff --git a/graylog2-web-interface/src/components/common/Select/PaginatedSelect.tsx b/graylog2-web-interface/src/components/common/Select/PaginatedSelect.tsx index 089ccfc3bf..37ae933df4 100644 --- a/graylog2-web-interface/src/components/common/Select/PaginatedSelect.tsx +++ b/graylog2-web-interface/src/components/common/Select/PaginatedSelect.tsx @@ -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(() => {