Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/en_US/release_notes_9_16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Bug fixes
| `Issue #9595 <https://github.com/pgadmin-org/pgadmin4/issues/9595>`_ - Fix missing ALTER ... SET DEFAULT statements for inherited columns in the generated table SQL/EDIT script.
| `Issue #9677 <https://github.com/pgadmin-org/pgadmin4/issues/9677>`_ - Fix the Unlogged table toggle in table properties not generating any ALTER TABLE ... SET LOGGED/UNLOGGED statement.
| `Issue #9828 <https://github.com/pgadmin-org/pgadmin4/issues/9828>`_ - Fix tool calls failing against OpenAI-compatible providers that emit empty/null name, arguments, or id fields in streaming continuation deltas.
| `Issue #9868 <https://github.com/pgadmin-org/pgadmin4/issues/9868>`_ - Warn before opening a very large JSON/JSONB value in the data grid cell editor, which could freeze pgAdmin, and let the user choose whether to proceed.
| `Issue #9875 <https://github.com/pgadmin-org/pgadmin4/issues/9875>`_ - Fixed an issue where EXPLAIN and EXPLAIN ANALYZE failed to execute when blank lines separated clauses in the SQL query.
| `Issue #9810 <https://github.com/pgadmin-org/pgadmin4/issues/9810>`_ - Use the ServerManager's passfile for the credential gate in connect() so the check matches the passfile actually used for the connection, and warn on conflicting passfile/passexec settings.
| `Issue #9892 <https://github.com/pgadmin-org/pgadmin4/issues/9892>`_ - Fix blank difference counts on the top-level group rows in Schema Diff.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import CloseIcon from '@mui/icons-material/Close';
import gettext from 'sources/gettext';
import JSONBigNumber from 'json-bignumber';
import JsonEditor from '../../../../../../static/js/components/JsonEditor';
import { shouldWarnForLargeJSON } from './json_editor_warning';
import PropTypes from 'prop-types';
import { RowInfoContext } from '.';
import { usePgAdmin } from '../../../../../../static/js/PgAdminProvider';
Expand Down Expand Up @@ -388,7 +389,7 @@ export function CheckboxEditor({row, column, onRowChange, onClose}) {
}
CheckboxEditor.propTypes = EditorPropTypes;

export function JsonTextEditor({row, column, onRowChange, onClose}) {
function JsonTextEditorContent({row, column, onRowChange, onClose}) {

const pgAdmin = usePgAdmin();

Expand Down Expand Up @@ -475,4 +476,42 @@ export function JsonTextEditor({row, column, onRowChange, onClose}) {
</Portal>
);
}
JsonTextEditorContent.propTypes = EditorPropTypes;

// Wraps the JSON editor with a guard that warns before opening a very large
// value. Rendering/parsing a huge JSON document blocks the main thread and can
// freeze pgAdmin, so for large cells we render nothing until the user confirms
// (#9868). The editor uses commitOnOutsideClick: false, so showing the confirm
// dialog does not dismiss the editor.
export function JsonTextEditor(props) {
const { row, column, onClose } = props;
const pgAdmin = usePgAdmin();
const [proceed, setProceed] = React.useState(
() => !shouldWarnForLargeJSON(row[column.key])
);

React.useEffect(() => {
if (proceed) return;
// notifier.confirm() fires its cancel callback whenever the dialog
// closes, including when the user clicks OK. Track the confirmation so
// we only close the cell editor on an actual cancel and not after the
// user chose to continue (#9868).
let confirmed = false;
pgAdmin.Browser.notifier.confirm(
gettext('Large data'),
gettext('This cell contains a large amount of data. Opening it in the JSON editor may make pgAdmin slow or unresponsive. Do you want to continue?'),
() => {
confirmed = true;
setProceed(true);
},
() => {
if (confirmed) return;
onClose(false);
},
);
}, []);

if (!proceed) return null;
return <JsonTextEditorContent {...props} />;
}
JsonTextEditor.propTypes = EditorPropTypes;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////

// Opening a very large JSON/JSONB value in the cell editor parses, pretty-
// prints and renders the whole document on the main thread, which can make
// pgAdmin slow or completely unresponsive. Above this size (in characters) we
// warn the user and let them decide whether to proceed. See issue #9868.
export const LARGE_JSON_WARNING_LENGTH = 1024 * 1024;

// Returns true when the raw cell value is large enough that opening it in the
// JSON editor warrants a confirmation prompt. Only string values are measured;
// the cheap length check avoids serializing the value just to size it.
export function shouldWarnForLargeJSON(value, threshold = LARGE_JSON_WARNING_LENGTH) {
return typeof value === 'string' && value.length > threshold;
}
134 changes: 134 additions & 0 deletions web/regression/javascript/sqleditor/json_editor_warning.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////

import { render, screen, act } from '@testing-library/react';

import {
LARGE_JSON_WARNING_LENGTH,
shouldWarnForLargeJSON,
} from '../../../pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/json_editor_warning';

// Stub the heavy JSON editor so the wrapper can render without CodeMirror.
jest.mock('../../../pgadmin/static/js/components/JsonEditor', () => ({
__esModule: true,
default: () => <div data-testid="json-editor" />,
}));

// Mock the QueryToolDataGrid index so importing Editors does not pull in the
// whole data grid; Editors only needs RowInfoContext from it.
jest.mock('../../../pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid', () => {
const ReactActual = require('react');
return { RowInfoContext: ReactActual.createContext() };
});

import Theme from 'sources/Theme';
import { JsonTextEditor } from '../../../pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Editors';
import { RowInfoContext } from '../../../pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid';
import { PgAdminProvider } from '../../../pgadmin/static/js/PgAdminProvider';

describe('QueryToolDataGrid json_editor_warning', () => {
describe('shouldWarnForLargeJSON', () => {
it('does not warn for small string values', () => {
expect(shouldWarnForLargeJSON('{"a":1}')).toBe(false);
expect(shouldWarnForLargeJSON('')).toBe(false);
});

it('warns once a string value exceeds the threshold (#9868)', () => {
const big = 'x'.repeat(LARGE_JSON_WARNING_LENGTH + 1);
expect(shouldWarnForLargeJSON(big)).toBe(true);
});

it('does not warn exactly at the threshold', () => {
const atLimit = 'x'.repeat(LARGE_JSON_WARNING_LENGTH);
expect(shouldWarnForLargeJSON(atLimit)).toBe(false);
});

it('respects a custom threshold', () => {
expect(shouldWarnForLargeJSON('abcd', 3)).toBe(true);
expect(shouldWarnForLargeJSON('abc', 3)).toBe(false);
});

it('does not warn for non-string values', () => {
expect(shouldWarnForLargeJSON(null)).toBe(false);
expect(shouldWarnForLargeJSON(undefined)).toBe(false);
expect(shouldWarnForLargeJSON(12345)).toBe(false);
expect(shouldWarnForLargeJSON([1, 2, 3])).toBe(false);
expect(shouldWarnForLargeJSON({ a: 1 })).toBe(false);
});
});

describe('JsonTextEditor large-value guard', () => {
const KEY = 'col';
let confirmArgs;
let onClose;

const renderEditor = (value) => {
const column = { key: KEY, idx: 0, can_edit: true };
const pgAdmin = {
Browser: {
notifier: {
confirm: (title, text, onOk, onCancel) => {
confirmArgs = { title, text, onOk, onCancel };
},
error: jest.fn(),
},
},
};
return render(
<Theme>
<PgAdminProvider value={pgAdmin}>
<RowInfoContext.Provider value={{ getCellElement: () => null }}>
<JsonTextEditor
row={{ [KEY]: value }}
column={column}
onRowChange={jest.fn()}
onClose={onClose}
/>
</RowInfoContext.Provider>
</PgAdminProvider>
</Theme>
);
};

beforeEach(() => {
confirmArgs = undefined;
onClose = jest.fn();
});

it('opens the editor immediately for small values without confirming', () => {
renderEditor('{"a":1}');
expect(confirmArgs).toBeUndefined();
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
});

it('warns for large values and waits before opening the editor', () => {
renderEditor('x'.repeat(LARGE_JSON_WARNING_LENGTH + 1));
expect(confirmArgs).toBeDefined();
expect(screen.queryByTestId('json-editor')).not.toBeInTheDocument();
});

it('closes the editor when the warning is cancelled', () => {
renderEditor('x'.repeat(LARGE_JSON_WARNING_LENGTH + 1));
act(() => confirmArgs.onCancel());
expect(onClose).toHaveBeenCalledWith(false);
});

it('opens the editor and does not close it when the user continues (#9868)', () => {
renderEditor('x'.repeat(LARGE_JSON_WARNING_LENGTH + 1));
// notifier.confirm() fires the cancel callback as well as the OK
// callback when the user clicks OK; the guard must keep the editor open.
act(() => {
confirmArgs.onOk();
confirmArgs.onCancel();
});
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
});
});
});
Loading