This is an automated email from the ASF dual-hosted git repository.

michaelsmolina pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 810d6ff4807 fix(sqllab): Resolve stale closure bug causing text 
selection to break (#37550)
810d6ff4807 is described below

commit 810d6ff4807f6e2f236a79b7c92aa1a9f2a50d97
Author: Michael S. Molina <[email protected]>
AuthorDate: Thu Jan 29 13:09:26 2026 -0300

    fix(sqllab): Resolve stale closure bug causing text selection to break 
(#37550)
---
 .../EditorWrapper/EditorWrapper.test.tsx           |  55 +++++-
 .../src/SqlLab/components/EditorWrapper/index.tsx  |  33 +++-
 .../src/core/editors/AceEditorProvider.test.tsx    | 191 +++++++++++++++++++++
 .../src/core/editors/AceEditorProvider.tsx         |  79 ++++++---
 4 files changed, 327 insertions(+), 31 deletions(-)

diff --git 
a/superset-frontend/src/SqlLab/components/EditorWrapper/EditorWrapper.test.tsx 
b/superset-frontend/src/SqlLab/components/EditorWrapper/EditorWrapper.test.tsx
index 3252f716579..e903d97f262 100644
--- 
a/superset-frontend/src/SqlLab/components/EditorWrapper/EditorWrapper.test.tsx
+++ 
b/superset-frontend/src/SqlLab/components/EditorWrapper/EditorWrapper.test.tsx
@@ -17,7 +17,12 @@
  * under the License.
  */
 import reducerIndex from 'spec/helpers/reducerIndex';
-import { render, waitFor, createStore } from 'spec/helpers/testing-library';
+import {
+  render,
+  waitFor,
+  createStore,
+  act,
+} from 'spec/helpers/testing-library';
 import { QueryEditor } from 'src/SqlLab/types';
 import { Store } from 'redux';
 import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
@@ -26,6 +31,7 @@ import type { editors } from '@apache-superset/core';
 import {
   queryEditorSetCursorPosition,
   queryEditorSetDb,
+  queryEditorSetSelectedText,
 } from 'src/SqlLab/actions/sqlLab';
 import fetchMock from 'fetch-mock';
 
@@ -124,4 +130,51 @@ describe('EditorWrapper', () => {
     store.dispatch(queryEditorSetDb(defaultQueryEditor, 2));
     expect(MockEditorHost).toHaveBeenCalledTimes(renderCount + 1);
   });
+
+  test('clears selectedText when selection becomes empty', async () => {
+    const store = createStore(initialState, reducerIndex);
+    // Set initial selected text in store
+    store.dispatch(
+      queryEditorSetSelectedText(defaultQueryEditor, 'SELECT * FROM table'),
+    );
+    setup(defaultQueryEditor, store);
+
+    await waitFor(() => expect(MockEditorHost).toHaveBeenCalled());
+
+    // Get the onSelectionChange and onReady callbacks from the mock
+    const lastCall =
+      MockEditorHost.mock.calls[MockEditorHost.mock.calls.length - 1][0];
+    const { onSelectionChange, onReady } = lastCall;
+
+    // Simulate editor ready with a mock handle that returns empty selection
+    const mockHandle = {
+      getSelectedText: jest.fn().mockReturnValue(''),
+      getValue: jest.fn().mockReturnValue(''),
+      setValue: jest.fn(),
+      focus: jest.fn(),
+      moveCursorToPosition: jest.fn(),
+      scrollToLine: jest.fn(),
+    };
+    act(() => {
+      onReady(mockHandle);
+    });
+
+    // Simulate selection change with empty selection (cursor moved without 
selecting)
+    act(() => {
+      onSelectionChange([
+        { start: { line: 0, column: 5 }, end: { line: 0, column: 5 } },
+      ]);
+    });
+
+    // Verify selectedText was cleared in the store
+    await waitFor(() => {
+      const state = store.getState() as unknown as {
+        sqlLab: { queryEditors: QueryEditor[] };
+      };
+      const editor = state.sqlLab.queryEditors.find(
+        qe => qe.id === defaultQueryEditor.id,
+      );
+      expect(editor?.selectedText).toBeFalsy();
+    });
+  });
 });
diff --git a/superset-frontend/src/SqlLab/components/EditorWrapper/index.tsx 
b/superset-frontend/src/SqlLab/components/EditorWrapper/index.tsx
index e6b5ac1afea..0d7002de081 100644
--- a/superset-frontend/src/SqlLab/components/EditorWrapper/index.tsx
+++ b/superset-frontend/src/SqlLab/components/EditorWrapper/index.tsx
@@ -216,16 +216,35 @@ const EditorWrapper = ({
         end: { line: number; column: number };
       }>,
     ) => {
-      if (editorHandleRef.current && selections.length > 0) {
-        const selectedText = editorHandleRef.current.getSelectedText();
-        if (
-          selectedText !== currentSelectionCache.current &&
-          selectedText.length !== 1
-        ) {
-          dispatch(queryEditorSetSelectedText(queryEditor, selectedText));
+      if (!editorHandleRef.current || selections.length === 0) {
+        return;
+      }
+
+      const selectedText = editorHandleRef.current.getSelectedText();
+
+      // Always clear selection state when text is empty, regardless of cache
+      if (!selectedText) {
+        if (currentSelectionCache.current) {
+          dispatch(queryEditorSetSelectedText(queryEditor, null));
         }
+        currentSelectionCache.current = '';
+        return;
+      }
+
+      // Skip 1-character selections to avoid noise from backspace operations
+      // which briefly select single characters. Trade-off: genuine single-char
+      // selections won't update Redux, but this is acceptable since running
+      // a single character as a query is not a practical use case.
+      if (selectedText.length === 1) {
         currentSelectionCache.current = selectedText;
+        return;
+      }
+
+      // Only dispatch if selection actually changed
+      if (selectedText !== currentSelectionCache.current) {
+        dispatch(queryEditorSetSelectedText(queryEditor, selectedText));
       }
+      currentSelectionCache.current = selectedText;
     },
     [dispatch, queryEditor],
   );
diff --git a/superset-frontend/src/core/editors/AceEditorProvider.test.tsx 
b/superset-frontend/src/core/editors/AceEditorProvider.test.tsx
new file mode 100644
index 00000000000..7f3b422639d
--- /dev/null
+++ b/superset-frontend/src/core/editors/AceEditorProvider.test.tsx
@@ -0,0 +1,191 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState, ReactElement } from 'react';
+import {
+  render as rtlRender,
+  screen,
+  waitFor,
+  cleanup,
+} from '@testing-library/react';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import { ThemeProvider, supersetTheme } from '@apache-superset/core/ui';
+import type { editors } from '@apache-superset/core';
+
+type EditorProps = editors.EditorProps;
+
+const mockEventHandlers: Record<string, (() => void) | undefined> = {};
+
+const mockEditor = {
+  focus: jest.fn(),
+  getCursorPosition: jest.fn(() => ({ row: 1, column: 5 })),
+  getSelection: jest.fn(() => ({
+    getRange: () => ({
+      start: { row: 0, column: 0 },
+      end: { row: 0, column: 10 },
+    }),
+  })),
+  commands: { addCommand: jest.fn() },
+  selection: {
+    on: jest.fn((event: string, handler: () => void) => {
+      mockEventHandlers[event] = handler;
+    }),
+  },
+};
+
+let mockOnLoadCallback: ((editor: typeof mockEditor) => void) | undefined;
+
+jest.mock('@superset-ui/core/components', () => ({
+  __esModule: true,
+  FullSQLEditor: jest.fn((props: { onLoad?: () => void }) => {
+    mockOnLoadCallback = props.onLoad;
+    return <div data-test="sql-editor" />;
+  }),
+  JsonEditor: () => <div data-test="json-editor" />,
+  MarkdownEditor: () => <div data-test="markdown-editor" />,
+  CssEditor: () => <div data-test="css-editor" />,
+  ConfigEditor: () => <div data-test="config-editor" />,
+}));
+
+import AceEditorProvider from './AceEditorProvider';
+
+const render = (ui: ReactElement) =>
+  rtlRender(<ThemeProvider theme={supersetTheme}>{ui}</ThemeProvider>);
+
+afterEach(() => {
+  cleanup();
+  jest.clearAllMocks();
+  mockOnLoadCallback = undefined;
+  Object.keys(mockEventHandlers).forEach(key => delete mockEventHandlers[key]);
+});
+
+const defaultProps: EditorProps = {
+  id: 'test-editor',
+  value: 'SELECT * FROM table',
+  onChange: jest.fn(),
+  language: 'sql',
+};
+
+const renderEditor = (props: Partial<EditorProps> = {}) => {
+  const result = render(<AceEditorProvider {...defaultProps} {...props} />);
+  if (mockOnLoadCallback) {
+    mockOnLoadCallback(mockEditor);
+  }
+  return result;
+};
+
+test('onSelectionChange uses latest callback after prop change', async () => {
+  const firstCallback = jest.fn();
+  const secondCallback = jest.fn();
+
+  const CallbackSwitcher = () => {
+    const [useSecond, setUseSecond] = useState(false);
+    return (
+      <>
+        <button type="button" onClick={() => setUseSecond(true)}>
+          Switch
+        </button>
+        <AceEditorProvider
+          {...defaultProps}
+          onSelectionChange={useSecond ? secondCallback : firstCallback}
+        />
+      </>
+    );
+  };
+
+  render(<CallbackSwitcher />);
+
+  if (mockOnLoadCallback) {
+    mockOnLoadCallback(mockEditor);
+  }
+
+  await waitFor(() => expect(mockEventHandlers.changeSelection).toBeDefined());
+
+  mockEventHandlers.changeSelection!();
+  expect(firstCallback).toHaveBeenCalled();
+  expect(secondCallback).not.toHaveBeenCalled();
+  firstCallback.mockClear();
+
+  await userEvent.click(screen.getByRole('button', { name: /switch/i }));
+  mockEventHandlers.changeSelection!();
+
+  expect(secondCallback).toHaveBeenCalled();
+  expect(firstCallback).not.toHaveBeenCalled();
+});
+
+test('onCursorPositionChange uses latest callback after prop change', async () 
=> {
+  const firstCallback = jest.fn();
+  const secondCallback = jest.fn();
+
+  const CallbackSwitcher = () => {
+    const [useSecond, setUseSecond] = useState(false);
+    return (
+      <>
+        <button type="button" onClick={() => setUseSecond(true)}>
+          Switch
+        </button>
+        <AceEditorProvider
+          {...defaultProps}
+          onCursorPositionChange={useSecond ? secondCallback : firstCallback}
+        />
+      </>
+    );
+  };
+
+  render(<CallbackSwitcher />);
+
+  if (mockOnLoadCallback) {
+    mockOnLoadCallback(mockEditor);
+  }
+
+  await waitFor(() => expect(mockEventHandlers.changeCursor).toBeDefined());
+
+  mockEventHandlers.changeCursor!();
+  expect(firstCallback).toHaveBeenCalled();
+  expect(secondCallback).not.toHaveBeenCalled();
+  firstCallback.mockClear();
+
+  await userEvent.click(screen.getByRole('button', { name: /switch/i }));
+  mockEventHandlers.changeCursor!();
+
+  expect(secondCallback).toHaveBeenCalled();
+  expect(firstCallback).not.toHaveBeenCalled();
+});
+
+test('cursor position callback receives correct position format', async () => {
+  const onCursorPositionChange = jest.fn();
+  renderEditor({ onCursorPositionChange });
+
+  await waitFor(() => expect(mockEventHandlers.changeCursor).toBeDefined());
+  mockEventHandlers.changeCursor!();
+
+  expect(onCursorPositionChange).toHaveBeenCalledWith({ line: 1, column: 5 });
+});
+
+test('selection callback receives correct range format', async () => {
+  const onSelectionChange = jest.fn();
+  renderEditor({ onSelectionChange });
+
+  await waitFor(() => expect(mockEventHandlers.changeSelection).toBeDefined());
+  mockEventHandlers.changeSelection!();
+
+  expect(onSelectionChange).toHaveBeenCalledWith([
+    { start: { line: 0, column: 0 }, end: { line: 0, column: 10 } },
+  ]);
+});
diff --git a/superset-frontend/src/core/editors/AceEditorProvider.tsx 
b/superset-frontend/src/core/editors/AceEditorProvider.tsx
index 018be92f2df..6e18e3e0f36 100644
--- a/superset-frontend/src/core/editors/AceEditorProvider.tsx
+++ b/superset-frontend/src/core/editors/AceEditorProvider.tsx
@@ -31,6 +31,7 @@ import {
   useCallback,
   useImperativeHandle,
   forwardRef,
+  useMemo,
   type Ref,
 } from 'react';
 import type AceEditor from 'react-ace';
@@ -222,15 +223,42 @@ const AceEditorProvider = forwardRef<EditorHandle, 
EditorProps>(
       new Map(),
     );
 
-    // Create the handle
-    const handle = createAceEditorHandle(aceEditorRef, completionProviders);
+    // Use refs to store latest callbacks to avoid stale closures in event 
listeners
+    const onCursorPositionChangeRef = useRef(onCursorPositionChange);
+    const onSelectionChangeRef = useRef(onSelectionChange);
+
+    // Keep refs up to date
+    useEffect(() => {
+      onCursorPositionChangeRef.current = onCursorPositionChange;
+    }, [onCursorPositionChange]);
+
+    useEffect(() => {
+      onSelectionChangeRef.current = onSelectionChange;
+    }, [onSelectionChange]);
+
+    // Create the handle (memoized to prevent recreation on every render)
+    const handle = useMemo(
+      () => createAceEditorHandle(aceEditorRef, completionProviders),
+      [],
+    );
 
     // Expose handle via ref
-    useImperativeHandle(ref, () => handle, []);
+    useImperativeHandle(ref, () => handle, [handle]);
 
-    // Notify when ready
+    // Track if onReady has been called to prevent multiple calls
+    const onReadyCalledRef = useRef(false);
+
+    // Track if event listeners have been registered to prevent duplicates
+    const listenersRegisteredRef = useRef(false);
+
+    // Notify when ready (only once)
     useEffect(() => {
-      if (onReady && aceEditorRef.current?.editor) {
+      if (
+        onReady &&
+        aceEditorRef.current?.editor &&
+        !onReadyCalledRef.current
+      ) {
+        onReadyCalledRef.current = true;
         onReady(handle);
       }
     }, [onReady, handle]);
@@ -249,34 +277,39 @@ const AceEditorProvider = forwardRef<EditorHandle, 
EditorProps>(
           });
         }
 
-        // Set up cursor position change listener
-        if (onCursorPositionChange) {
+        // Only register event listeners once to prevent duplicates
+        if (!listenersRegisteredRef.current) {
+          listenersRegisteredRef.current = true;
+
+          // Set up cursor position change listener using ref to avoid stale 
closures
           editor.selection.on('changeCursor', () => {
-            const cursor = editor.getCursorPosition();
-            onCursorPositionChange({
-              line: cursor.row,
-              column: cursor.column,
-            });
+            if (onCursorPositionChangeRef.current) {
+              const cursor = editor.getCursorPosition();
+              onCursorPositionChangeRef.current({
+                line: cursor.row,
+                column: cursor.column,
+              });
+            }
           });
-        }
 
-        // Set up selection change listener
-        if (onSelectionChange) {
+          // Set up selection change listener using ref to avoid stale closures
           editor.selection.on('changeSelection', () => {
-            const range = editor.getSelection().getRange();
-            onSelectionChange([
-              {
-                start: { line: range.start.row, column: range.start.column },
-                end: { line: range.end.row, column: range.end.column },
-              },
-            ]);
+            if (onSelectionChangeRef.current) {
+              const range = editor.getSelection().getRange();
+              onSelectionChangeRef.current([
+                {
+                  start: { line: range.start.row, column: range.start.column },
+                  end: { line: range.end.row, column: range.end.column },
+                },
+              ]);
+            }
           });
         }
 
         // Focus the editor
         editor.focus();
       },
-      [hotkeys, onCursorPositionChange, onSelectionChange, handle],
+      [hotkeys, handle],
     );
 
     // Handle blur

Reply via email to