Legend selection from Plotly graph isn't cleared correctly

I have recently run into a bug regarding the legend selection from Plotly graphs in my Dash app.
I’m working with a few tabs, the last of which displays several interconnected Plotly graphs and components.
One of the graphs is considered as “the main graph”, and making a selection from this graph results in changes in the other components displayed.
When switching to another tab after a selection from the legend has been made and cleared (by double-clicking on an item then double-clicking again), then switching back, a new selection from the legend will not be properly reset.

I’ve included a minimal code example to reproduce the behaviour:

import json
import random
import pandas as pd
pd.options.plotting.backend = "plotly"

import dash
from dash.dependencies import Input, Output, State
import dash_table as dt
import dash_core_components as dcc
import dash_html_components as html
import dash_bootstrap_components as dbc


external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css',
						"https://codepen.io/chriddyp/pen/brPBPO.css"]
app = dash.Dash(__name__, external_stylesheets=external_stylesheets)

# -------------------- DATA --------------------

plot_df = pd.DataFrame({
		'x': [1,2,3,4,5],
		'y': [1,2,3,4,5],
		'text': ['a', 'b', 'c', 'd', 'e'],
		'label': [-1, -1, -1, -1, -1]
	})

# -------------------- LAYOUTS --------------------
tab1_layout = html.Div(
			[
				html.H1('Tab 1'),
				html.P('Please navigate to tab 2.')
			]
)

tab2_layout = html.Div(
			[
				html.Div([html.H1('Tab 2')]),

				# Slider
				html.Div(
					[
						html.H3(
							children=['Slider'],
						),

						dcc.Slider(
							id="slider",
							min=2,
							max=5,
							step=1,
							value=2,
							marks={
								2: "2",
								5: "5",
							},
							disabled=False,
						),
					]
				),

				# Graph
				html.Div(
					[
						html.H3(
							children=['Graph'],
						),

						dcc.Graph(
							id="graph",
							config={
								'modeBarButtonsToRemove': ['autoScale2d', 'zoomIn2d', 'zoomOut2d', 'resetScale2d', 'hoverClosestCartesian', 'hoverCompareCartesian', 'toggleSpikelines'],
								'displaylogo': False
								}
						),
					]
				),

				# DataTable
				html.Div(
					[
						html.H3(
							children=['DataTable'],
						),

						dt.DataTable(
							id='data_table',
							sort_action="native",
							page_action="native",
							page_current= 0,
							page_size= 100,
							style_as_list_view=True
						),
					]
				),
			],
			style={
				'width':'50%',
			},
		)

app.layout = html.Div(
	[	
		dcc.Store(id='store_data', data=plot_df.to_json()),
		dcc.Store(id='displayed_legends'),
		dcc.Tabs(
			id='main_tabs', 
			value='tab1',
			children=[
				dcc.Tab(id="tab_1", label='1. Tab 1', value='tab1', children=[tab1_layout]),
				dcc.Tab(id="tab_2", label='2. Tab 2', value='tab2', children=[tab2_layout])
			],
		),
	]
)

# -------------------- CALLBACKS --------------------

@app.callback(
[Output('graph', 'figure'),
Output('store_data', 'data')],

Input('slider', 'value'),

State('store_data', 'data')
)
def update_graph_from_slider(n, data_json) :
	plot_df = pd.read_json(data_json)

	labels_int = list(range(0, n))
	k = len(plot_df) - len(labels_int)
	if k > 0:
		labels_int += random.choices(range(0, n), k=k)
	labels = [str(e) for e in labels_int]

	plot_df['label'] = labels

	plot_df = plot_df.sort_values(by='label', ascending=True)
	plot_fig = plot_df.plot.scatter(x='x', y='y', hover_data={'x': False, 'y': False, 'label': True, 'text': True} ,color='label')

	return plot_fig, plot_df.to_json()

@app.callback(
	Output('displayed_legends', 'data'),
	Input('graph', 'restyleData'),
	State('displayed_legends', 'data')
)
def update_selected_legend_items(restyleEvent, displayedLegendsItems):
	if displayedLegendsItems == None:
		displayedLegendsItems = {}
	if restyleEvent != None:
		states = restyleEvent[0]['visible']
		labels = restyleEvent[1]
		for index in range(len(restyleEvent[0]['visible'])):
			if states[index] == 'legendonly':#legend item has been deactivated
				displayedLegendsItems[labels[index]] = False
			elif states[index] == True:#legend item has been activated
				displayedLegendsItems[labels[index]]  = True
	return displayedLegendsItems

@app.callback(
[Output('data_table', 'columns'),
Output('data_table', 'data'),],

[Input('graph', 'selectedData'),
Input('graph', 'relayoutData'),
Input('displayed_legends', 'data')],

State('store_data', 'data'),
)
def display_selected_data(selectedData, relayoutData, selectedTopics, data_json):
	res = get_filtered_nodes(data_json, selectedTopics, selectedData)
	df_table = pd.DataFrame(res)
	visibility = {}
	changed_id = [p['prop_id'] for p in dash.callback_context.triggered][0]
	if 'graph' in changed_id:
		if 'relayoutData' in changed_id:
			relayoutDataKeys = relayoutData.keys()
			# if we zoomed, auto zoomed or rescaled,don't update the list
			if 'dragmode' in relayoutDataKeys or 'xaxis.range[0]' in relayoutDataKeys or 'xaxis.autorange' in relayoutDataKeys:
				res = dash.no_update
	if res != dash.no_update and len(res) == 0:
		visibility = {'display':'none'}

	return  [{"name": i, "id": i} for i in df_table.columns], df_table.to_dict('records')

# -------------------- FUNCTIONS --------------------
def get_filtered_nodes(data, selectedTopics, selectedData):
	res = []
	selectedTopicsKeys = selectedTopics.keys()

	if selectedData != None:#There is an active selection
		pointsList = json.loads(json.dumps(selectedData))
		if pointsList != None and len(pointsList['points']) > 0:#Some points are selected
			for point in pointsList['points']:
				text = point['customdata'][1]
				label = point['customdata'][0]
				if label not in selectedTopicsKeys or selectedTopics[label] == True:
					res.append({'text': text, 'label': label})
	elif data != None:#No active selection
		dataArray = json.loads(data)
		textArray = dataArray['text']
		if 'label' in dataArray :
			labelsArray = dataArray['label']

		for key in textArray.keys():
			if labelsArray[key] not in selectedTopicsKeys or selectedTopics[labelsArray[key]] == True:
				res.append({'text': textArray[key], 'label': labelsArray[key]})
	return res


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

Librairies used (and their version):

  • pandas==1.3.3
  • dash==1.20.0
  • dash-core-components==1.16.0
  • dash-bootstrap-components==0.12.2
  • dash-html-components==1.1.3
  • dash-table==4.11.3
    I am using python 3.9.

And here are the detailed step to replicate the bug:

  • run the app as usual
  • go to tab 2
  • select any item from the legend (by double-clicking on it)
  • clear selection (by double-clicking anywhere on the legend again)
  • go to tab 1
  • go back to tab 2
  • select any item from the legend (by double-clicking on it)
  • clear selection by double-clicking again: the datatable is not updated
  • optional: select new value from slider
  • optional: select any item from the legend (by double-clicking on it)
  • optional: clear selection by double-clicking yet again: this time, it works as intended (from my experiments, the bug only affects the last value where a selection has been made before switching tabs).

Looking a bit into this bug, it appears that the restyleData property from the graph, which is normally used to detect a change in selection from the legend, isn’t updated when the selection is cleared and thus retains the previously selected item.
Any insight would be greatly appreciated :slight_smile: