plotly / dash

Data Apps & Dashboards for Python. No JavaScript Required.
https://plotly.com/dash
MIT License
21.54k stars 2.08k forks source link

[BUG] Component value changing without user interaction or callbacks firing #2946

Closed Casper-Guo closed 3 months ago

Casper-Guo commented 3 months ago

Thank you so much for helping improve the quality of Dash!

We do our best to catch bugs during the release process, but we rely on your help to find the ones that slip through.

Describe your context Please provide us your environment, so we can easily reproduce the issue.

Describe the bug

A clear and concise description of what the bug is.

Expected behavior

The app has the following layout:

session_picker_row = dbc.Row(
    [
        ...
        dbc.Col(
            dcc.Dropdown(
                options=[],
                placeholder="Select a session",
                value=None,
                id="session",
            ),
        ),
        ...
        dbc.Col(
            dbc.Button(
                children="Load Session / Reorder Drivers",
                n_clicks=0,
                disabled=True,
                color="success",
                id="load-session",
            )
        ),
    ],
)

The dataflow within callbacks is unidirectional from session to load-session using the following callback:

@callback(
    Output("load-session", "disabled"),
    Input("season", "value"),
    Input("event", "value"),
    Input("session", "value"),
    prevent_initial_call=True,
)
def enable_load_session(season: int | None, event: str | None, session: str | None) -> bool:
    """Toggles load session button on when the previous three fields are filled."""
    return not (season is not None and event is not None and session is not None)

I have noticed that sometimes the n_click property of load-session, which starts from 0, goes to 1 and drops back down to 0. Simultaneously, the value property of session would revert to None which is what I initialize it with. This is all without any callback firing.

The line causing this behavior is editing a cached (with dcc.store) dataframe and doesn't trigger any callback. Might this have something to do with the browser cache?

CNFeffery commented 3 months ago

@Casper-Guo Can you provide complete sample code that reliably reproduces the problem.

Casper-Guo commented 3 months ago

Here is the repo that contains the code for the app. The buggy version is on the dashboard-add-gap branch.

Build with pip install -e . and then run the app. Once the app is opened, most input to the dropdowns in the first row will trigger the buggy behavior. To reproduce consistently, select 2024, "Belgian Grand Prix" (the last option), and "Race" before hitting the load session button.

I briefly tried minimizing the app but this bug seems quite sensitive to removing any components. I have reproduced the entire file here but let me know if you would like any clarification or assistance with minimizing.

AnnMarieW commented 3 months ago

@Casper-Guo - It's necessary for you to provide a complete minimal example that reproduces the issue. What you provided has way too much code and will take too long to debug. You can find more information on how to make a good example on the forum: https://community.plotly.com/t/how-to-get-your-questions-answered-on-the-plotly-forum/40551

CNFeffery commented 3 months ago

@Casper-Guo Change this line, extend() just return None:

image

Casper-Guo commented 3 months ago

Apologies for that. I was just trying to show the behavior and see if it is something easily recognizable.

Here is a substantially minimized version that has the same behavior. Fastf1 is the only external dependency.

"""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)
CNFeffery commented 3 months ago

Apologies for that. I was just trying to show the behavior and see if it is something easily recognizable.

Here is a substantially minimized version that has the same behavior. Fastf1 is the only external dependency.

"""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)

The crux of the problem is the same, options received an illegal null value in the callback function.

Casper-Guo commented 3 months ago

@CNFeffery You are correct about the fix. Much appreciated. Always forgets that append and extend return None.

The error shown by the minimal example in the debug environment is just node is null with super cryptic and unhelpful traceback. Is this a dash implementation limitation or something owing to the dcb package?

CNFeffery commented 3 months ago

@Casper-Guo This error for an array type parameter occurs before passing in a specific component rendering, in my opinion, the unclear error prompt in this scenario is an issue that needs to be improved in the dash-renderer logic, !node?.length is better.

https://github.com/plotly/dash/blob/129c942fb1f675e9204f0d26b367afc13dd6a005/dash/dash-renderer/src/TreeContainer.js#L325