Skip to content

Commit 051b423

Browse files
authored
Merge pull request #3785 from plotly/fix/graph-patch-clicks
Fix graph patch & duplicate clicks
2 parents b5ce179 + 776c6e1 commit 051b423

4 files changed

Lines changed: 174 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ This project adheres to [Semantic Versioning](https://semver.org/).
99
- [#3669](https://github.com/plotly/dash/pull/3669) Selection for DataTable cleared with custom action settings
1010
- [#3680](https://github.com/plotly/dash/pull/3680) Added `search_order` prop to `Dropdown` to allow users to preserve original option order during search
1111
- Added `csrf_token_name` and `csrf_header_name` config options to allow configuring the CSRF cookie and header names. Fixes [#729](https://github.com/plotly/dash/issues/729)
12-
13-
## Added
1412
- [#3523](https://github.com/plotly/dash/pull/3523) Fall back to background callback function names if source cannot be found
13+
- [#3785](https://github.com/plotly/dash/pull/3785) Fix patch with dcc.Graph figure.
14+
- [#3785](https://github.com/plotly/dash/pull/3785) Fix dcc.Graph not sending duplicate clicks because it had the same payload by adding a timestamp in the click event object.
1515

1616
## Fixed
1717
- [#3690](https://github.com/plotly/dash/pull/3690) Fixes Input when min or max is set to None

components/dash-core-components/src/fragments/Graph.react.js

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
mergeDeepRight,
1010
omit,
1111
type,
12+
clone,
1213
} from 'ramda';
1314
import PropTypes from 'prop-types';
1415
import {graphPropTypes, graphDefaultProps} from '../components/Graph.react';
@@ -313,10 +314,12 @@ class PlotlyGraph extends Component {
313314
return mergeDeepRight(config, this.getConfigOverride(responsive));
314315
}
315316

316-
getLayout(layout, responsive) {
317-
if (!layout) {
318-
return layout;
317+
getLayout(originalLayout, responsive) {
318+
if (!originalLayout) {
319+
return originalLayout;
319320
}
321+
// Clone layout to avoid mutating the original (important for Patch)
322+
const layout = clone(originalLayout);
320323
const override = this.getLayoutOverride(responsive);
321324
const {override: prev_override, originals: prev_originals} = this.state;
322325
// Store the original data that we're about to override
@@ -339,7 +342,7 @@ class PlotlyGraph extends Component {
339342
for (const key in override) {
340343
layout[key] = override[key];
341344
}
342-
return layout; // not really a clone
345+
return layout;
343346
}
344347

345348
getConfigOverride(responsive) {
@@ -414,6 +417,8 @@ class PlotlyGraph extends Component {
414417
gd.on('plotly_click', eventData => {
415418
const clickData = filterEventData(gd, eventData, 'click');
416419
if (!isNil(clickData)) {
420+
// Add timestamp to ensure each click is unique (for DashWrapper deduplication)
421+
clickData.timestamp = Date.now();
417422
setProps({clickData});
418423
}
419424
});
@@ -422,6 +427,8 @@ class PlotlyGraph extends Component {
422427
['event', 'fullAnnotation'],
423428
eventData
424429
);
430+
// Add timestamp to ensure each click is unique (for DashWrapper deduplication)
431+
clickAnnotationData.timestamp = Date.now();
425432
setProps({clickAnnotationData});
426433
});
427434
gd.on('plotly_hover', eventData => {
@@ -444,6 +451,29 @@ class PlotlyGraph extends Component {
444451
if (!isNil(relayout) && !equals(relayout, relayoutData)) {
445452
setProps({relayoutData: relayout});
446453
}
454+
// Sync shapes from gd.layout to figure when shapes are modified by user
455+
// This is needed because getLayout() clones layout to prevent mutation issues
456+
if (eventData && gd.layout) {
457+
const hasShapeChanges = Object.keys(eventData).some(
458+
key => key === 'shapes' || key.startsWith('shapes[')
459+
);
460+
if (hasShapeChanges) {
461+
const {figure = {}} = this.props;
462+
const currentShapes = figure?.layout?.shapes;
463+
const newShapes = gd.layout.shapes;
464+
if (!equals(currentShapes, newShapes)) {
465+
setProps({
466+
figure: {
467+
...figure,
468+
layout: {
469+
...figure?.layout,
470+
shapes: newShapes,
471+
},
472+
},
473+
});
474+
}
475+
}
476+
}
447477
});
448478
gd.on('plotly_restyle', eventData => {
449479
const restyle = filterEventData(gd, eventData, 'restyle');

components/dash-core-components/tests/integration/graph/test_graph_basics.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,138 @@ def toggle_figure(n_clicks):
412412
)
413413

414414
assert dash_dcc.get_logs() == []
415+
416+
417+
def test_grbs009_graph_click_same_point_twice(dash_dcc):
418+
"""Clicking the same point twice should trigger callback both times."""
419+
app = Dash(__name__)
420+
421+
app.layout = html.Div(
422+
[
423+
dcc.Graph(
424+
id="graph",
425+
figure=go.Figure(
426+
data=[go.Scatter(x=[1, 2, 3], y=[1, 2, 3], mode="markers")],
427+
),
428+
style={"width": 600, "height": 400},
429+
),
430+
html.Div(id="output", children="[]"),
431+
]
432+
)
433+
434+
@app.callback(
435+
Output("output", "children"),
436+
Input("graph", "clickData"),
437+
prevent_initial_call=True,
438+
)
439+
def on_click(click_data):
440+
# Return the timestamp to verify each click has a unique one
441+
return json.dumps(click_data.get("timestamp", "missing"))
442+
443+
dash_dcc.start_server(app)
444+
dash_dcc.wait_for_element("#graph .main-svg")
445+
446+
# Click on the graph area - uses the drag overlay which receives clicks
447+
graph = dash_dcc.find_element("#graph .nsewdrag")
448+
graph.click()
449+
450+
# Wait for callback to fire and verify timestamp exists
451+
dash_dcc.wait_for_contains_text("#output", "")
452+
first_output = dash_dcc.find_element("#output").text
453+
assert first_output != "[]", "First click should trigger callback"
454+
assert first_output != '"missing"', "clickData should contain timestamp"
455+
456+
# Click again - should trigger callback with different timestamp
457+
graph.click()
458+
sleep(0.5)
459+
second_output = dash_dcc.find_element("#output").text
460+
assert (
461+
second_output != first_output
462+
), "Second click should trigger callback with new timestamp"
463+
464+
assert dash_dcc.get_logs() == []
465+
466+
467+
def test_grbs010_graph_patch_deeply_nested_figure(dash_dcc):
468+
"""Patching deeply nested figure properties should work correctly."""
469+
from dash import Patch
470+
471+
app = Dash(__name__)
472+
473+
app.layout = html.Div(
474+
[
475+
dcc.Graph(
476+
id="graph",
477+
figure={
478+
"data": [
479+
{
480+
"x": [1, 2, 3],
481+
"y": [1, 2, 3],
482+
"marker": {"color": "red", "size": 10},
483+
"type": "scatter",
484+
"mode": "markers",
485+
}
486+
],
487+
"layout": {"title": {"text": "Initial Title"}},
488+
},
489+
style={"width": 600, "height": 400},
490+
),
491+
html.Button("Patch Color", id="patch-color-btn"),
492+
html.Button("Patch Title", id="patch-title-btn"),
493+
html.Div(id="output"),
494+
]
495+
)
496+
497+
@app.callback(
498+
Output("graph", "figure"),
499+
Input("patch-color-btn", "n_clicks"),
500+
prevent_initial_call=True,
501+
)
502+
def patch_color(n):
503+
p = Patch()
504+
p.data[0].marker.color = "blue" if n % 2 else "green"
505+
return p
506+
507+
@app.callback(
508+
Output("graph", "figure", allow_duplicate=True),
509+
Input("patch-title-btn", "n_clicks"),
510+
prevent_initial_call=True,
511+
)
512+
def patch_title(n):
513+
p = Patch()
514+
p.layout.title.text = f"Updated Title {n}"
515+
return p
516+
517+
@app.callback(
518+
Output("output", "children"),
519+
Input("graph", "figure"),
520+
)
521+
def show_figure_state(figure):
522+
color = figure.get("data", [{}])[0].get("marker", {}).get("color", "unknown")
523+
title = figure.get("layout", {}).get("title", {}).get("text", "unknown")
524+
return f"color={color}, title={title}"
525+
526+
dash_dcc.start_server(app)
527+
dash_dcc.wait_for_element("#graph .main-svg")
528+
529+
# Initial state
530+
dash_dcc.wait_for_text_to_equal("#output", "color=red, title=Initial Title")
531+
532+
# Patch the color
533+
dash_dcc.find_element("#patch-color-btn").click()
534+
dash_dcc.wait_for_text_to_equal("#output", "color=blue, title=Initial Title")
535+
536+
# Patch the title
537+
dash_dcc.find_element("#patch-title-btn").click()
538+
dash_dcc.wait_for_text_to_equal("#output", "color=blue, title=Updated Title 1")
539+
540+
# Patch color again - should toggle
541+
dash_dcc.find_element("#patch-color-btn").click()
542+
dash_dcc.wait_for_text_to_equal("#output", "color=green, title=Updated Title 1")
543+
544+
# Multiple rapid patches
545+
dash_dcc.find_element("#patch-title-btn").click()
546+
dash_dcc.find_element("#patch-title-btn").click()
547+
dash_dcc.wait_for_text_to_equal("#output", "color=green, title=Updated Title 3")
548+
549+
assert dash_dcc.get_logs() == []

tests/integration/callbacks/test_arbitrary_callbacks.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import time
22
from multiprocessing import Value
33

4+
from flaky import flaky
5+
46
from dash import (
57
Dash,
68
Input,
@@ -234,6 +236,7 @@ def test_arb007_clientside_no_output(dash_duo):
234236
dash_duo.wait_for_text_to_equal("#output", "start2")
235237

236238

239+
@flaky(max_runs=3)
237240
def test_arb008_set_props_chain_cb(dash_duo):
238241
app = Dash(suppress_callback_exceptions=True)
239242

0 commit comments

Comments
 (0)