diff --git a/CHANGELOG.md b/CHANGELOG.md index a4bb9f02..a3de4212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Links "DE#nnn" prior to version 2.0 point to the Dash Enterprise closed-source D ### Fixed - [#454](https://github.com/plotly/dash-ag-grid/pull/454) fixes issue where a rowCount of 0 would cause the grid not to display new data +- [#456](https://github.com/plotly/dash-ag-grid/pull/456) fixes `rowTransaction` pre-mount buffering/replay and prevents stale transaction retention in the wrapper ## [35.2.0] - 2026-04-03 ### Added diff --git a/src/lib/components/AgGrid.react.js b/src/lib/components/AgGrid.react.js index ce2e1c3a..f03ffdbb 100644 --- a/src/lib/components/AgGrid.react.js +++ b/src/lib/components/AgGrid.react.js @@ -43,13 +43,17 @@ function DashAgGrid(props) { }); const buildArray = useCallback((arr1, arr2) => { - if (arr1) { - if (!arr1.includes(arr2)) { - return [...arr1, arr2]; - } + if (!arr1) { + return [arr2]; + } + const nextSerialized = JSON.stringify(arr2); + const serializedTransactions = new Set( + arr1.map((transaction) => JSON.stringify(transaction)) + ); + if (serializedTransactions.has(nextSerialized)) { return arr1; } - return [JSON.parse(JSON.stringify(arr2))]; + return [...arr1, arr2]; }, []); useEffect(() => { @@ -64,12 +68,25 @@ function DashAgGrid(props) { } }, [props.rowTransaction, state.mounted, buildArray]); + const onJsGridMounted = useCallback(() => { + setState((prevState) => ({ + ...prevState, + mounted: true, + rowTransaction: null, + })); + }, []); + const {enableEnterpriseModules} = props; const RealComponent = getGrid(enableEnterpriseModules); return ( - + ); } diff --git a/src/lib/fragments/AgGrid.react.js b/src/lib/fragments/AgGrid.react.js index dd5ed434..c3074b42 100644 --- a/src/lib/fragments/AgGrid.react.js +++ b/src/lib/fragments/AgGrid.react.js @@ -74,6 +74,7 @@ const xssMessage = (context) => { }; const NO_CONVERT_PROPS = [...PASSTHRU_PROPS, ...PROPS_NOT_FOR_AG_GRID]; +const OMIT_GRID_INTERNAL_PROPS = ['parentState', 'onJsGridMounted']; const dash_clientside = window.dash_clientside || {}; @@ -273,7 +274,9 @@ export function DashAgGrid(props) { const [, forceRerender] = useState({}); const [openGroups, setOpenGroups] = useState({}); const [columnState_push, setColumnState_push] = useState(true); - const [rowTransactionState, setRowTransactionState] = useState(null); + const [rowTransactionState, setRowTransactionState] = useState( + props.parentState?.rowTransaction || null + ); const resettingCount = useRef(false); const prevRowCountRef = useRef(null); const resetTimeoutRef = useRef(null); @@ -299,6 +302,22 @@ export function DashAgGrid(props) { const getDetailParams = useRef(); const getRowsParams = useRef(null); const pendingCellValueChanges = useRef(null); + const onJsGridMountedRef = useRef(props.onJsGridMounted); + onJsGridMountedRef.current = props.onJsGridMounted; + + useEffect(() => { + onJsGridMountedRef.current?.(); + }, []); + + useEffect(() => { + if ( + !gridApi && + props.parentState?.rowTransaction && + props.parentState.rowTransaction !== rowTransactionState + ) { + setRowTransactionState(props.parentState.rowTransaction); + } + }, [props.parentState, rowTransactionState, gridApi]); const onPaginationChanged = useCallback(() => { if (gridApi && !gridApi?.isDestroyed()) { @@ -1235,33 +1254,38 @@ export function DashAgGrid(props) { ); const buildArray = useCallback((arr1, arr2) => { - if (arr1) { - if (!arr1.includes(arr2)) { - return [...arr1, arr2]; - } + if (!arr1) { + return [arr2]; + } + const nextSerialized = JSON.stringify(arr2); + const serializedTransactions = new Set( + arr1.map((transaction) => JSON.stringify(transaction)) + ); + if (serializedTransactions.has(nextSerialized)) { return arr1; } - return [JSON.parse(JSON.stringify(arr2))]; + return [...arr1, arr2]; }, []); const rowTransaction = useCallback( (data) => { const rowTransaction = rowTransactionState; if (gridApi && !gridApi?.isDestroyed()) { - if (rowTransaction) { - rowTransaction.forEach(applyRowTransaction); - setRowTransactionState(null); + const isAlreadyQueued = + rowTransaction && + rowTransaction.some((transaction) => + equals(transaction, data) + ); + if (!isAlreadyQueued) { + applyRowTransaction(data); } - applyRowTransaction(data); customSetProps({ rowTransaction: null, }); syncRowData(); } else { setRowTransactionState( - rowTransaction - ? buildArray(rowTransaction, data) - : [JSON.parse(JSON.stringify(data))] + rowTransaction ? buildArray(rowTransaction, data) : [data] ); } }, @@ -1585,7 +1609,10 @@ export function DashAgGrid(props) { const {id, style, className, dashGridOptions, ...restProps} = props; const passingProps = pick(PASSTHRU_PROPS, restProps); const convertedProps = convertAllProps( - omit(NO_CONVERT_PROPS, {...dashGridOptions, ...restProps}) + omit([...NO_CONVERT_PROPS, ...OMIT_GRID_INTERNAL_PROPS], { + ...dashGridOptions, + ...restProps, + }) ); if ('theme' in convertedProps) { @@ -1669,7 +1696,11 @@ export function DashAgGrid(props) { ); } -DashAgGrid.propTypes = {parentState: PropTypes.any, ..._propTypes}; +DashAgGrid.propTypes = { + parentState: PropTypes.any, + onJsGridMounted: PropTypes.func, + ..._propTypes, +}; export const propTypes = DashAgGrid.propTypes; diff --git a/tests/test_selection_persistence.py b/tests/test_selection_persistence.py index 645efe99..8200c432 100644 --- a/tests/test_selection_persistence.py +++ b/tests/test_selection_persistence.py @@ -139,3 +139,114 @@ def setSelections(n, n1, n2): dash_duo.wait_for_text_to_equal( "#selectedRows", '[{"make": "Ford", "model": "Mondeo", "price": 32000}]' ) + + +def test_sp002_row_transaction_before_grid_ready(dash_duo): + app = Dash(__name__) + + rowData = [ + {"id": "Toyota_0", "make": "Toyota", "model": "Celica", "price": 35000}, + {"id": "Ford_0", "make": "Ford", "model": "Mondeo", "price": 32000}, + {"id": "Porsche_0", "make": "Porsche", "model": "Boxster", "price": 72000}, + ] + + app.layout = html.Div( + [ + dcc.Interval(id="tick", interval=1, n_intervals=0, max_intervals=1), + html.Div(id="rowTransaction-state"), + dag.AgGrid( + id="grid", + rowData=rowData, + columnDefs=[ + {"field": "id"}, + {"field": "make"}, + {"field": "model"}, + {"field": "price"}, + ], + defaultColDef={"flex": 1}, + getRowId="params.data.id", + ), + ] + ) + + @app.callback(Output("grid", "rowTransaction"), Input("tick", "n_intervals")) + def apply_transaction(n): + if not n: + return dash.no_update + return { + "add": [ + { + "id": "Queued_1", + "make": "Queued", + "model": "Queued", + "price": 1, + } + ] + } + + @app.callback( + Output("rowTransaction-state", "children"), Input("grid", "rowTransaction") + ) + def expose_row_transaction_state(value): + return json.dumps(value) + + dash_duo.start_server(app) + + grid = utils.Grid(dash_duo, "grid") + grid.wait_for_cell_text(0, 0, "Toyota_0") + grid.wait_for_cell_text(1, 0, "Ford_0") + grid.wait_for_cell_text(2, 0, "Porsche_0") + grid.wait_for_cell_text(3, 0, "Queued_1") + grid.wait_for_rendered_rows(4) + dash_duo.wait_for_text_to_equal("#rowTransaction-state", "null") + + +def test_sp003_duplicate_row_transaction_before_grid_ready(dash_duo): + app = Dash(__name__) + + rowData = [ + {"id": "Toyota_0", "make": "Toyota", "model": "Celica", "price": 35000}, + {"id": "Ford_0", "make": "Ford", "model": "Mondeo", "price": 32000}, + {"id": "Porsche_0", "make": "Porsche", "model": "Boxster", "price": 72000}, + ] + + app.layout = html.Div( + [ + dcc.Interval(id="tick", interval=1, n_intervals=0, max_intervals=2), + dag.AgGrid( + id="grid", + rowData=rowData, + columnDefs=[ + {"field": "id"}, + {"field": "make"}, + {"field": "model"}, + {"field": "price"}, + ], + defaultColDef={"flex": 1}, + ), + ] + ) + + @app.callback(Output("grid", "rowTransaction"), Input("tick", "n_intervals")) + def apply_duplicate_transaction(n): + if not n: + return dash.no_update + return { + "add": [ + { + "id": "Queued_1", + "make": "Queued", + "model": "Queued", + "price": 1, + } + ] + } + + dash_duo.start_server(app) + + grid = utils.Grid(dash_duo, "grid") + grid.wait_for_cell_text(0, 0, "Toyota_0") + grid.wait_for_cell_text(1, 0, "Ford_0") + grid.wait_for_cell_text(2, 0, "Porsche_0") + grid.wait_for_cell_text(3, 0, "Queued_1") + grid.wait_for_rendered_rows(4)