diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 3525bf96ace1..6d9e18c9723a 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -28640,7 +28640,8 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isempty": { "version": "4.4.0", @@ -28652,7 +28653,8 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.ismatch": { "version": "4.4.0", @@ -44057,7 +44059,7 @@ "math-expression-evaluator": "^2.0.7", "parse-ms": "^4.0.0", "re-resizable": "^6.11.2", - "react-ace": "^14.0.1", + "react-ace": "^15.0.0", "react-draggable": "^4.7.1", "react-error-boundary": "^6.1.2", "react-js-cron": "^6.0.2", @@ -44168,16 +44170,24 @@ "@types/trusted-types": "^2.0.7" } }, + "packages/superset-ui-core/node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "packages/superset-ui-core/node_modules/react-ace": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz", - "integrity": "sha512-z6YAZ20PNf/FqmYEic//G/UK6uw0rn21g58ASgHJHl9rfE4nITQLqthr9rHMVQK4ezwohJbp2dGrZpkq979PYQ==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz", + "integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==", "license": "MIT", "dependencies": { "ace-builds": "^1.36.3", "diff-match-patch": "^1.0.5", - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", + "fast-equals": "^5.3.3", "prop-types": "^15.8.1" }, "peerDependencies": { diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index 78a335d02b81..21ab1134ef4c 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -77,7 +77,7 @@ "math-expression-evaluator": "^2.0.7", "parse-ms": "^4.0.0", "re-resizable": "^6.11.2", - "react-ace": "^14.0.1", + "react-ace": "^15.0.0", "react-draggable": "^4.7.1", "react-error-boundary": "^6.1.2", "react-js-cron": "^6.0.2", diff --git a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/AsyncAceEditor.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/AsyncAceEditor.test.tsx index db5ac8da798d..0af0e62cff2a 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/AsyncAceEditor.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/AsyncAceEditor.test.tsx @@ -604,6 +604,61 @@ test('cleans up event listeners on unmount', async () => { offSpy.mockRestore(); }); +test('re-applies annotations only when their content actually changes across renders (react-ace 15 fast-equals regression guard)', async () => { + // react-ace's componentDidUpdate decides whether to call + // session.setAnnotations() by deep-comparing the new/old `annotations` + // prop (lib/ace.js, using an internal deep-equality helper -- lodash's + // isEqual through react-ace 14.x, fast-equals's deepEqual from 15.0.0 + // onward). Superset's own AceEditorProvider/EditorWrapper always pass a + // freshly `.map()`-derived annotations array on every render, so this + // guards the actual behavior Superset relies on: a same-content-but- + // different-reference array must NOT re-trigger setAnnotations (or the + // editor would thrash on every keystroke-driven re-render), while a + // genuinely different array must still update the editor. + const ref = createRef(); + const annotationsV1 = [{ row: 0, column: 0, type: 'error', text: 'oops' }]; + + const { rerender, container } = render( + } annotations={annotationsV1} />, + ); + + await waitFor(() => { + expect(container.querySelector(selector)).toBeInTheDocument(); + }); + + const session = ref.current?.editor?.getSession(); + expect(session).toBeDefined(); + if (!session) return; + + // The initial mount already applies annotations via componentDidMount, + // not componentDidUpdate, so start observing only from the first update. + const setAnnotationsSpy = jest.spyOn(session, 'setAnnotations'); + + // Same content, new array/object references -- must be a no-op. + const annotationsV1SameContent = [ + { row: 0, column: 0, type: 'error', text: 'oops' }, + ]; + rerender( + } + annotations={annotationsV1SameContent} + />, + ); + expect(setAnnotationsSpy).not.toHaveBeenCalled(); + + // Genuinely different content -- must update, with the new value. + const annotationsV2 = [ + { row: 1, column: 2, type: 'warning', text: 'different' }, + ]; + rerender( + } annotations={annotationsV2} />, + ); + expect(setAnnotationsSpy).toHaveBeenCalledTimes(1); + expect(setAnnotationsSpy).toHaveBeenCalledWith(annotationsV2); + + setAnnotationsSpy.mockRestore(); +}); + test('does not move autocomplete popup if target container is document.body', async () => { const ref = createRef(); const { container } = render(} />);