Changing pandas dataframe causes mutation on button's n_clicks property

I have the following callback that runs when a button is clicked

@callback(
    Output("laps", "data"),
    Input("load-session", "n_clicks"),
    State("season", "value"),
    State("event", "value"),
    State("session", "value"),
    prevent_initial_call=True,
)
def get_session_laps(
    _: int,  # ignores actual_value of n_clicks
    season: int,
    event: str,
    session: str,
) -> dict:
    """
    Save the laps of the selected session into browser cache.

    Can assume that season, event, and session are all set (not None).
    """
    included_laps = DF_DICT[season][session]
    included_laps = included_laps[included_laps["EventName"] == event]
    included_laps = included_laps.drop(columns=["Time", "PitOutTime", "PitInTime"])

    return included_laps.to_dict()

The n_clicks property start at 0. The first time the button is clicked and this callback is fired, the n_clicks go up to 1 before returning to 0. I have isolated that the line dropping some columns from the dataframe is causing this behavior.

This is totally unexpected. Where should I look to investigate further?

Relevant section of the layout:

session_picker_row = dbc.Row(
    [
        dbc.Col(
            dcc.Dropdown(
                options=list(range(CURRENT_SEASON, 2017, -1)),
                placeholder="Select a season",
                value=None,
                id="season",
            )
        ),
        dbc.Col(
            dcc.Dropdown(
                options=[],
                placeholder="Select a event",
                value=None,
                id="event",
            ),
        ),
        dbc.Col(
            dcc.Dropdown(
                options=[],
                placeholder="Select a session",
                value=None,
                id="session",
            ),
        ),
        dbc.Col(
            dcc.Dropdown(
                options=[
                    {"label": "Finishing order", "value": False},
                    {"label": "Teammate side-by-side", "value": True},
                ],
                value=False,
                clearable=False,
                id="teammate-comp",
            )
        ),
        dbc.Col(
            dbc.Button(
                children="Load Session / Reorder Drivers",
                n_clicks=0,
                disabled=True,
                color="success",
                id="load-session",
            )
        ),
    ],
)

The button’s disabled property is toggled by another callback.

I don’t remember ever seeing n_clicks change its value in this unexpected way, and I don’t think I can work out what’s going on from your extracts - would it be possible for you give a complete working example?

(I guess CURRENT_SEASON is a global? Does the value of this ever get changed?)

Yes CURRENT_SEASON is a global. It is never assigned to.

You can find the full app here. To reproduce use the dashboard_add_gap branch. The buggy behavior is triggered by most input to the top dropdowns but 2024, Belgian Grand Prix, Race shows it consistently.

Here is a minimal program that has the same behavior:

"""Dash app layout and callbacks."""

import dash_bootstrap_components as dbc
import fastf1 as f
from dash import Dash, Input, Output, callback, dcc

# setup
min_session = f.get_session(2024, 14, "R")
min_session.load(telemetry=False)
laps = min_session.laps
MIN_LAPS = laps[["Time", "PitInTime", "PitOutTime"]]


scatter_y_options = [
    {"label": "Lap Time", "value": "LapTime"},
]

scatter_y_dropdown = dcc.Dropdown(
    options=scatter_y_options,
    value="LapTime",
    clearable=False,
    id="scatter-y",
)

app = Dash(
    __name__,
    external_stylesheets=[dbc.themes.SANDSTONE],
)
app.layout = dbc.Container(
    [
        dbc.Button(
            children="Load Session / Reorder Drivers",
            n_clicks=0,
            color="success",
            id="load-session",
        ),
        dcc.Store(id="laps"),
        scatter_y_dropdown,
    ]
)


@callback(
    Output("laps", "data"),
    Input("load-session", "n_clicks"),
    prevent_initial_call=True,
)
def get_session_laps(
    _: int,  # ignores actual_value of n_clicks
) -> dict:
    """Save the laps of the selected session into browser cache."""
    df = MIN_LAPS
    df = df.drop(columns=["Time", "PitInTime", "PitOutTime"])
    return df.to_dict()


@callback(
    Output("scatter-y", "options"),
    Input("laps", "data"),
    prevent_initial_call=True,
)
def set_y_axis_dropdowns(data: dict) -> list[dict[str, str]]:
    """Update y axis options based on the columns in the laps dataframe."""
    gap_cols = filter(lambda x: x.startswith("Gap"), data.keys())  # should be empty
    gap_col_options = [{"label": col, "value": col} for col in gap_cols]
    return scatter_y_options.extend(gap_col_options)


if __name__ == "__main__":
    app.run(debug=True)

In a separate github issue, someone has figured out that the problem is with the second callback. The dictionary extend method returns None