Issues with using dmc.ChipGroup as custom cell renderer in dash-ag-grid

I am trying to present 10 unique options (chips) for each row using dash-ag-grid and dmc.ChipGroup as a custom cell renderer.

The issue I run into is that for more than a trivial amount of rows, the application first stops registering changes and eventually becomes completely unresponsive. Developer tools hints at a memory leak. I am not familiar with js/react and have put this together from other examples + GPT so hard for me to troubleshoot appropriately. Hoping someone (like the resident forum genius @AnnMarieW) can help

Package Versions

dash==2.17.0
dash-mantine-components==0.12.1
dash-ag-grid==31.2.0

Python MRE

import dash
from dash import html, Input, Output, State
from dash.exceptions import PreventUpdate
import dash_ag_grid as dag
import pandas as pd
import dash_mantine_components as dmc

app = dash.Dash(__name__)
no_of_rows = 2

data = {
    "RecommendedMatches": [
        [
            {
                "value": f"Match {i}",
                "children": f"This is Match {i}",
                "tooltip": f"Custom Tooltip {i}",
            }
            for i in range(1, 11)
        ]
    ]
    * no_of_rows,
    "SelectedChip": ["X"] * no_of_rows,  # meaningless initial value for mre
}

df = pd.DataFrame(data)
df["index"] = df.index

# Define the ag-Grid column definitions
column_defs = [
    {
        "headerName": "index",
        "field": "index",
        # "hide": True,
    },
    {
        "headerName": "Recommended Matches",
        "field": "RecommendedMatches",
        "cellRenderer": "DMC_ChipGroup",
        "autoHeight": True,
    },
    {
        "headerName": "Selected Chip",
        "field": "SelectedChip",
    },
]

# Create the Dash layout
app.layout = html.Div(
    [
        dmc.LoadingOverlay(
            dag.AgGrid(
                id="grid",
                rowModelType="clientSide",
                columnDefs=column_defs,
                rowData=df.to_dict("records"),
                columnSize="sizeToFit",
                getRowId="params.data.index",
                style={"height": "600px"},
            ),
        ),
    ]
)


# Callback to capture the selected chip value
@app.callback(
    [
        Output("grid", "rowTransaction"),
    ],
    [
        Input("grid", "cellRendererData"),
    ],
    [
        State("grid", "rowData"),
    ],
    prevent_initial_call=True,
)
def display_selected_chip(cell_changed, grid_data_raw):
    if cell_changed and "value" in cell_changed:
        new_value = cell_changed["value"]
        ix_changed = cell_changed["rowId"]
        grid_data_raw = pd.DataFrame(grid_data_raw)
        grid_data = grid_data_raw.loc[grid_data_raw["index"] == int(ix_changed)].copy(
            deep=True
        )
        grid_data["SelectedChip"] = new_value
        print(ix_changed, new_value)

        return [
            {
                "update": grid_data.to_dict(orient="records"),
            },
        ]

    raise PreventUpdate


if __name__ == "__main__":
    app.run_server(host="0.0.0.0", debug=True, port=8282)

dashAgGridFunctions.js

////////////////////////////////////////
// CHIP GROUP 
///////////////////////////////////////
var dagcomponentfuncs = (window.dashAgGridComponentFunctions = window.dashAgGridComponentFunctions || {});

dagcomponentfuncs.DMC_ChipGroup = function (props) {
    const { setData, colId, node, rowIndex, value } = props;
    const [selectedChip, setSelectedChip] = React.useState(value[0]?.value || null);

    function handleChipClick(chipValue) {
        console.log("Selected chip value:", chipValue); // Debug statement to print the selected chip value
        setSelectedChip(chipValue);
        if (setData) {
            setData(chipValue);
        }
    }

    return React.createElement(
        window.dash_mantine_components.ChipGroup,
        { onChange: handleChipClick, multiple: false, value: selectedChip, style: { display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1px' }, align: 'center', position: 'center' },
        value.map(chipData => 
            React.createElement(window.dash_mantine_components.Tooltip, {
                key: chipData.value, // Ensure each Tooltip has a unique key
                label: React.createElement(window.dash_core_components.Markdown, {
                    children: chipData.tooltip,
                    dangerously_allow_html: true,
                }),
                multiline: true,                
            }, 
                React.createElement(window.dash_mantine_components.Chip, {
                    key: chipData.value, // Ensure each Chip has a unique key
                    value: chipData.value,
                    variant: chipData.variant,
                    color: chipData.color,                
                    size: 'sm',
                }, chipData.children)
            )
        )
    );
};

With the no_of_rows parameter set to 2, everything works as expected without any errors.
ezgif-3-624d28c1a1

When the grid is larger, e.g. no_of_rows = 100, it works for a selection or two but then the applicaiton becomes unresponsive

When no_of_rows is large (e.g. no_of_rows = 100)

ezgif-3-19ac502984

image

Any help at all would be greatly appreciated - my entire application bar this feature is ready and I would hate to have to change things up at this stage.

P.S > For the MRE, to make it easier, I’ve kept the options consistent in all rows. However, in my application each row will have a list of unique 10 options hence the usual RadioGroup example where options are set at the column level does not work for my use case.

Hello @uns123,

Is there a reason why you need to update this value on the backend, couldnt it just be part of a value getter to pull the value from the other column?

As far are your memory leaks, its probably from the cell renderer being constantly added and removed.

Is there a reason why you need to update this value on the backend, couldn’t it just be part of a value getter to pull the value from the other column?

In my application, based on the chip value, the selected chip cell is updated with a lot of information not contained in this grid’s data (it utilizes another store - did not put it in the MRE to keep things simple) so I would need to maintain this pattern

Can it just be a one way trip?

There is no reason to force the grid to render the data again, unless you are wanting that data to come back to the grid for some reason?

@jinnyzor - what do you mean by a one way trip?.

The idea is that the user selects a chip out of the 10 presented. The value of the chip is then used to filter another dataframe (contained in a different store) for more information about the selected chip. This ‘more information’ is updated in the grid in different columns using rowTransaction. I can expand the example above to make it clearer if that would help?

The only thing you are displaying in the column next to the chip is the selected value, and you are filtering another grid with the selections?

One way trip, meaning the data only need to go to the backend and not come back to the grid, since the grid already knows what you selected. :wink:

I will expand the MRE to demonstrate why the round trip is necessary. Essentially, the chip value is used to filter a pandas dataframe contained in a dcc.Store (pulled in another tab from a different source) and this info is presented on the grid depending on what the user has selected

ezgif-3-a41144a4e6

Extended MRE

import dash
from dash import html, Input, Output, State, dcc
from dash.exceptions import PreventUpdate
import dash_ag_grid as dag
import pandas as pd
import dash_mantine_components as dmc
from random import randint

app = dash.Dash(__name__)
no_of_rows = 100

df = pd.DataFrame(
    {
        **{
            "RecommendedMatches": [
                [
                    {
                        "value": f"Match {i}",
                        "children": f"This is Match {i}",
                        "tooltip": f"Custom Tooltip {i}",
                    }
                    for i in range(1, 11)
                ]
            ]
            * no_of_rows,
            "SelectedChip": ["X"] * no_of_rows,  # meaningless initial value for mre
        },
        **{f"Feature_{f}": [0] * no_of_rows for f in range(1, 5)},
    }
)
df["index"] = df.index.astype(str)

some_other_df = pd.DataFrame(
    {
        **{
            "ChipID": [f"Match {i}" for i in range(1, 11)] * no_of_rows,
            "RowID": [f"{i}" for i in range(no_of_rows) for _ in range(1, 11)],
        },
        **(
            {
                f"Feature_{f}": [randint(1, 1000) for i in range(1, 11)] * no_of_rows
                for f in range(1, 5)
            }
        ),
    }
)

# Define the ag-Grid column definitions
column_defs = [
    {
        "headerName": "index",
        "field": "index",
        # "hide": True,
    },
    {
        "headerName": "Recommended Matches",
        "field": "RecommendedMatches",
        "cellRenderer": "DMC_ChipGroup",
        "autoHeight": True,
    },
    {
        "headerName": "Selected Chip",
        "field": "SelectedChip",
    },
] + [{"headerName": f"Feature_{f}", "field": f"Feature_{f}"} for f in range(1, 5)]

# Create the Dash layout
app.layout = html.Div(
    [
        dmc.LoadingOverlay(
            dag.AgGrid(
                id="grid",
                rowModelType="clientSide",
                columnDefs=column_defs,
                rowData=df.to_dict("records"),
                columnSize="autoSize",
                getRowId="params.data.index",
                style={"height": "600px"},
            ),
        ),
        dcc.Store(id="store", data=some_other_df.to_dict("records")),
    ]
)


# Callback to capture the selected chip value
@app.callback(
    [
        Output("grid", "rowTransaction"),
    ],
    [
        Input("grid", "cellRendererData"),
    ],
    [
        State("grid", "rowData"),
        State("store", "data"),
    ],
    prevent_initial_call=True,
)
def display_selected_chip(cell_changed, grid_data_raw, store_df):
    if cell_changed and "value" in cell_changed:
        new_value = cell_changed["value"]
        ix_changed = cell_changed["rowId"]
        grid_data_raw = pd.DataFrame(grid_data_raw)
        grid_data = grid_data_raw.loc[grid_data_raw["index"] == ix_changed].copy(
            deep=True
        )
        grid_data["SelectedChip"] = new_value

        store_df = pd.DataFrame(store_df)

        # Filter the dataframe in the store for the specific row and chip selected
        addl_info = store_df.loc[
            (store_df["RowID"] == ix_changed) & (store_df["ChipID"] == new_value),
            [f"Feature_{f}" for f in range(1, 5)],
        ]
        print(addl_info.head(1))
        grid_data.loc[:, [f"Feature_{f}" for f in range(1, 5)]] = addl_info.values

        return [
            {
                "update": grid_data.to_dict(orient="records"),
            },
        ]

    raise PreventUpdate


if __name__ == "__main__":
    app.run_server(host="0.0.0.0", debug=True, port=8282)

The expanded example also suffers from the same issue as the minimal MRE.

Have you considered using alignedGrids instead of this? This way, you wouldnt have to rerender the chip each time. Especially since you arent even storing the value.

Basically, you would have these be side by side and then you select on the left and it would alter your info on the right.

Aligned grids do not fit my use case (they need to have the same columns etc - this is part of a much larger application and even this grid is more complex i.e. some of the columns that are being fetched from the store end up becoming custom cell renderers (inputs and selects etc.).

Ideally, I’d like to fix this pattern rather than change my entire approach.

I’ve noticed with testing this, that the callback takes longer in some instances, I am guessing because of how the data is being fetched. The more data you have in the transfer, the longer it will take for the round trip from the server.

Is there not a way to do this clientside, since you are housing the data on the client?

Unfortunately, the store it will fetch from will be stored server side (using dash-extensions ServersideOutputTransform) as it is a humungous df.

Not sure if I can still do this clientside callbacks (if thats what you meant?). Wouldn’t know where to being with that as my poison of choice is Python and I assume clientside callbacks have to be written in JS :frowning:

Also note that with no_of_rows == 100, the application just completely crashes after the first one or two selects - so it seems like it might not just be a function of the transfer being slow, but something else?

Hi @uns123
Could you try it without the tooltip - just to see if that’s causing the performance issue?

Give this a shot, it uses two grids and syncs them, also removes your autoHeight with an actual calculation.

app.py

import dash
from dash import html, Input, Output, State, dcc
from dash.exceptions import PreventUpdate
import dash_ag_grid as dag
import pandas as pd
import dash_mantine_components as dmc
from random import randint

app = dash.Dash(__name__, 
                external_scripts=['https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js'])
no_of_rows = 1000

df = pd.DataFrame(
    {
        **{
            "RecommendedMatches": [
                [
                    {
                        "value": f"Match {i}",
                        "children": f"This is Match {i}",
                        "tooltip": f"Custom Tooltip {i}",
                    }
                    for i in range(1, 11)
                ]
            ]
            * no_of_rows,
            "SelectedChip": ["X"] * no_of_rows,  # meaningless initial value for mre
        },
        **{f"Feature_{f}": [0] * no_of_rows for f in range(1, 5)},
    }
)
df["index"] = df.index.astype(str)

some_other_df = pd.DataFrame(
    {
        **{
            "ChipID": [f"Match {i}" for i in range(1, 11)] * no_of_rows,
            "RowID": [f"{i}" for i in range(no_of_rows) for _ in range(1, 11)],
        },
        **(
            {
                f"Feature_{f}": [randint(1, 1000) for i in range(1, 11)] * no_of_rows
                for f in range(1, 5)
            }
        ),
    }
)

# Define the ag-Grid column definitions
column_defs = [
    {
        "headerName": "index",
        "field": "index",
        "flex": 1
        # "hide": True,
    },
    {
        "headerName": "Recommended Matches",
        "field": "RecommendedMatches",
        "cellRenderer": "DMC_ChipGroup",
        "flex": 3
        # "autoHeight": True,
    },
    {
        "headerName": "Selected Chip",
        "field": "SelectedChip",
    },
] + [{"headerName": f"Feature_{f}", "field": f"Feature_{f}"} for f in range(1, 5)]

# Create the Dash layout
app.layout = html.Div(
    [
        dmc.LoadingOverlay(
            [dag.AgGrid(
                id="grid",
                columnDefs=column_defs[:2],
                rowData=df.to_dict("records"),
                columnSize=None,
                getRowId="params.data.index",
                style={"height": "600px", "width": "25%"},
                dashGridOptions={'alignedGrids': ['grid2'],
                                 "getRowHeight":{"function": "getRowHeight(params)"},
                                 "debounceVerticalScrollbar": True},
                eventListeners={'bodyScroll': ['setViewPort(params, "grid2")'],
                                'bodyScrollEnd': ['resetListener(params, "grid2")']},

            ),
            dag.AgGrid(
                id='grid2',
                columnDefs=column_defs[2:],
                rowData=df.to_dict("records"),
                columnSize="autoSize",
                getRowId="params.data.index",
                style={"height": "600px", "width": "75%"},
                dashGridOptions={'alignedGrids': ['grid'],
                                 "getRowHeight":{"function": "getRowHeight(params)"},
                                 "debounceVerticalScrollbar": True},
                eventListeners={'bodyScroll': ['setViewPort(params, "grid")'],
                                'bodyScrollEnd': ['resetListener(params, "grid")']}
            )],
            style={'display': 'flex'}
        ),
        dcc.Store(id="store", data=some_other_df.to_dict("records")),
    ]
)


# Callback to capture the selected chip value
@app.callback(
    [
        Output("grid2", "rowTransaction"),
    ],
    [
        Input("grid", "cellRendererData"),
    ],
    [
        State("grid2", "rowData"),
        State("store", "data"),
    ],
    prevent_initial_call=True,
)
def display_selected_chip(cell_changed, grid_data_raw, store_df):
    if cell_changed and "value" in cell_changed:
        new_value = cell_changed["value"]
        ix_changed = cell_changed["rowId"]
        grid_data_raw = pd.DataFrame(grid_data_raw)
        grid_data = grid_data_raw.loc[grid_data_raw["index"] == ix_changed].copy(
            deep=True
        )
        grid_data["SelectedChip"] = new_value

        store_df = pd.DataFrame(store_df)

        # Filter the dataframe in the store for the specific row and chip selected
        addl_info = store_df.loc[
            (store_df["RowID"] == ix_changed) & (store_df["ChipID"] == new_value),
            [f"Feature_{f}" for f in range(1, 5)],
        ]

        grid_data.loc[:, [f"Feature_{f}" for f in range(1, 5)]] = addl_info.values

        return [
            {
                "update": grid_data.to_dict(orient="records"), 'async': False
            },
        ]

    raise PreventUpdate


if __name__ == "__main__":
    app.run(host="0.0.0.0", debug=True, port=8282)

js file

////////////////////////////////////////
// CHIP GROUP
///////////////////////////////////////
var dagcomponentfuncs = (window.dashAgGridComponentFunctions = window.dashAgGridComponentFunctions || {});

dagcomponentfuncs.DMC_ChipGroup = function (props) {
    const { setData, colId, node, rowIndex, value } = props;
    const [selectedChip, setSelectedChip] = React.useState(props.data ? props.data.SelectedChip : (value[0]?.value || null));

    function handleChipClick(chipValue) {
        console.log("Selected chip value:", chipValue); // Debug statement to print the selected chip value
        setSelectedChip(chipValue);
        props.data.SelectedChip = chipValue
        if (setData) {
            setData(chipValue);
        }
    }

    return React.createElement(
        window.dash_mantine_components.ChipGroup,
        { onChange: handleChipClick, multiple: false, value: selectedChip, style: { display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1px' }, align: 'center', position: 'center' },
        value.map(chipData =>
            React.createElement(window.dash_mantine_components.Tooltip, {
                key: chipData.value, // Ensure each Tooltip has a unique key
                label: React.createElement(window.dash_core_components.Markdown, {
                    children: chipData.tooltip,
                    dangerously_allow_html: true,
                }),
                multiline: true,
            },
                React.createElement(window.dash_mantine_components.Chip, {
                    key: chipData.value, // Ensure each Chip has a unique key
                    value: chipData.value,
                    variant: chipData.variant,
                    color: chipData.color,
                    size: 'sm',
                }, chipData.children)
            )
        )
    );
}

var listener = null

const setViewPort = (params, target) => {
    const {top, left} = params;
    el = document.getElementById(target).querySelector('.ag-body-viewport')
    console.log(el.matches(':hover'))
    if (el == listener || !listener) {
        el.scrollTo(left, top)
        listener = el
    }
}

dagfuncs.setViewPort = setViewPort

dagfuncs.resetListener = (params, target) => {
    setViewPort(params, target)
    setTimeout(() => {listener = null}, 50)
    }

dagfuncs.getRowHeight = (params) => {
    return params.data ? Math.round(params.data.RecommendedMatches.length / 2) * 40 : 20
}

As a note, if the user does a bunch of scrolling it can get out of sync, but this is really close. XD And it has 1k rows, with no slow down in selecting.

1 Like

Removing the tooltip has no effect on performance.

I tried running this with 100 rows and run into the same issue. Spinner keeps spinning after selecting a chip - sometimes it works for the a couple of selects - like two or three but then spinner just keeps spinning.

Only difference is that now I do get an error in Dash:

Is this in your live environment or the test app?

If your live environment, run the test app only and see if the problem persists.

Also, update to dash 2.17.1.

Done. I am running it in the live env. Lasts for about ~30 chip changes before freezing like before.

see list in develoepr tools for an idea

Think I’m going to switch to an html table with these dmc components and pattern matching callbacks

1 Like

Weird, I thought I had a ton of rows…

Is it possibly to take a look at your code?

There are ton of rows on my side too, I meant to say the application stops responding after ~30 chip clicks (this number is not consistent).