Skip to content

Commit 2097b71

Browse files
authored
fix(sqllab): race condition when updating cursor position (#30154)
1 parent acea58e commit 2097b71

File tree

4 files changed

+58
-17
lines changed

4 files changed

+58
-17
lines changed

superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,29 @@
1818
*/
1919
import configureStore from 'redux-mock-store';
2020
import thunk from 'redux-thunk';
21-
import { render, waitFor } from 'spec/helpers/testing-library';
21+
import reducerIndex from 'spec/helpers/reducerIndex';
22+
import { render, waitFor, createStore } from 'spec/helpers/testing-library';
2223
import { QueryEditor } from 'src/SqlLab/types';
2324
import { Store } from 'redux';
2425
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
2526
import AceEditorWrapper from 'src/SqlLab/components/AceEditorWrapper';
26-
import { AsyncAceEditorProps } from 'src/components/AsyncAceEditor';
27+
import {
28+
AsyncAceEditorProps,
29+
FullSQLEditor,
30+
} from 'src/components/AsyncAceEditor';
31+
import {
32+
queryEditorSetCursorPosition,
33+
queryEditorSetDb,
34+
} from 'src/SqlLab/actions/sqlLab';
35+
import fetchMock from 'fetch-mock';
2736

2837
const middlewares = [thunk];
2938
const mockStore = configureStore(middlewares);
3039

40+
fetchMock.get('glob:*/api/v1/database/*/function_names/', {
41+
function_names: [],
42+
});
43+
3144
jest.mock('src/components/Select/Select', () => () => (
3245
<div data-test="mock-deprecated-select-select" />
3346
));
@@ -36,9 +49,11 @@ jest.mock('src/components/Select/AsyncSelect', () => () => (
3649
));
3750

3851
jest.mock('src/components/AsyncAceEditor', () => ({
39-
FullSQLEditor: (props: AsyncAceEditorProps) => (
40-
<div data-test="react-ace">{JSON.stringify(props)}</div>
41-
),
52+
FullSQLEditor: jest
53+
.fn()
54+
.mockImplementation((props: AsyncAceEditorProps) => (
55+
<div data-test="react-ace">{JSON.stringify(props)}</div>
56+
)),
4257
}));
4358

4459
const setup = (queryEditor: QueryEditor, store?: Store) =>
@@ -59,6 +74,10 @@ const setup = (queryEditor: QueryEditor, store?: Store) =>
5974
);
6075

6176
describe('AceEditorWrapper', () => {
77+
beforeEach(() => {
78+
(FullSQLEditor as any as jest.Mock).mockClear();
79+
});
80+
6281
it('renders ace editor including sql value', async () => {
6382
const { getByTestId } = setup(defaultQueryEditor, mockStore(initialState));
6483
await waitFor(() => expect(getByTestId('react-ace')).toBeInTheDocument());
@@ -91,4 +110,19 @@ describe('AceEditorWrapper', () => {
91110
JSON.stringify({ value: defaultQueryEditor.sql }).slice(1, -1),
92111
);
93112
});
113+
114+
it('skips rerendering for updating cursor position', () => {
115+
const store = createStore(initialState, reducerIndex);
116+
setup(defaultQueryEditor, store);
117+
118+
expect(FullSQLEditor).toHaveBeenCalled();
119+
const renderCount = (FullSQLEditor as any as jest.Mock).mock.calls.length;
120+
const updatedCursorPosition = { row: 1, column: 9 };
121+
store.dispatch(
122+
queryEditorSetCursorPosition(defaultQueryEditor, updatedCursorPosition),
123+
);
124+
expect(FullSQLEditor).toHaveBeenCalledTimes(renderCount);
125+
store.dispatch(queryEditorSetDb(defaultQueryEditor, 1));
126+
expect(FullSQLEditor).toHaveBeenCalledTimes(renderCount + 1);
127+
});
94128
});

superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
*/
1919
import { useState, useEffect, useRef } from 'react';
2020
import type { IAceEditor } from 'react-ace/lib/types';
21-
import { useDispatch } from 'react-redux';
21+
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
2222
import { css, styled, usePrevious, useTheme } from '@superset-ui/core';
2323
import { Global } from '@emotion/react';
2424

@@ -27,7 +27,7 @@ import { queryEditorSetSelectedText } from 'src/SqlLab/actions/sqlLab';
2727
import { FullSQLEditor as AceEditor } from 'src/components/AsyncAceEditor';
2828
import type { KeyboardShortcut } from 'src/SqlLab/components/KeyboardShortcutButton';
2929
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
30-
import type { CursorPosition } from 'src/SqlLab/types';
30+
import { SqlLabRootState, type CursorPosition } from 'src/SqlLab/types';
3131
import { useAnnotations } from './useAnnotations';
3232
import { useKeywords } from './useKeywords';
3333

@@ -77,11 +77,20 @@ const AceEditorWrapper = ({
7777
'catalog',
7878
'schema',
7979
'templateParams',
80-
'cursorPosition',
8180
]);
81+
// Prevent a maximum update depth exceeded error
82+
// by skipping access the unsaved query editor state
83+
const cursorPosition = useSelector<SqlLabRootState, CursorPosition>(
84+
({ sqlLab: { queryEditors } }) => {
85+
const { cursorPosition } = {
86+
...queryEditors.find(({ id }) => id === queryEditorId),
87+
};
88+
return cursorPosition ?? { row: 0, column: 0 };
89+
},
90+
shallowEqual,
91+
);
8292

8393
const currentSql = queryEditor.sql ?? '';
84-
const cursorPosition = queryEditor.cursorPosition ?? { row: 0, column: 0 };
8594
const [sql, setSql] = useState(currentSql);
8695

8796
// The editor changeSelection is called multiple times in a row,
@@ -145,12 +154,7 @@ const AceEditorWrapper = ({
145154

146155
editor.selection.on('changeCursor', () => {
147156
const cursor = editor.getCursorPosition();
148-
const { row, column } = cursorPosition;
149-
// Prevent a maximum update depth exceeded error
150-
// by skipping repeated updates to the same cursor position
151-
if (cursor.row !== row && cursor.column !== column) {
152-
onCursorPositionChange(cursor);
153-
}
157+
onCursorPositionChange(cursor);
154158
});
155159

156160
const { row, column } = cursorPosition;

superset-frontend/src/SqlLab/components/AceEditorWrapper/useAnnotations.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ import { VALIDATION_DEBOUNCE_MS } from 'src/SqlLab/constants';
2424
import {
2525
FetchValidationQueryParams,
2626
useQueryValidationsQuery,
27+
ValidationResult,
2728
} from 'src/hooks/apiResources';
2829
import { useDebounceValue } from 'src/hooks/useDebounceValue';
2930

31+
const EMPTY = [] as ValidationResult[];
32+
3033
export function useAnnotations(params: FetchValidationQueryParams) {
3134
const { sql, dbId, schema, templateParams } = params;
3235
const debouncedSql = useDebounceValue(sql, VALIDATION_DEBOUNCE_MS);
@@ -73,7 +76,7 @@ export function useAnnotations(params: FetchValidationQueryParams) {
7376
text: `The server failed to validate your query.\n${message}`,
7477
},
7578
]
76-
: [],
79+
: EMPTY,
7780
};
7881
},
7982
},

superset-frontend/src/hooks/apiResources/queryValidations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export type FetchValidationQueryParams = {
2626
templateParams?: string;
2727
};
2828

29-
type ValidationResult = {
29+
export type ValidationResult = {
3030
end_column: number | null;
3131
line_number: number | null;
3232
message: string | null;

0 commit comments

Comments
 (0)