Skip to content

adds allow_optional to State and Input to place no value as the placeholders #3294

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## Fixed
- [#3279](https://github.com/plotly/dash/pull/3279) Fix an issue where persisted values were incorrectly pruned when updated via callback. Now, callback returned values are correctly stored in the persistence storage. Fix [#2678](https://github.com/plotly/dash/issues/2678)

## Added
- [#3294](https://github.com/plotly/dash/pull/3294) Added the ability to pass `allow_optional` to Input and State to allow callbacks to work even if these components are not in the dash layout.

## [3.0.4] - 2025-04-24

## Fixed
Expand Down
38 changes: 21 additions & 17 deletions dash/dash-renderer/src/actions/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,22 +122,27 @@ function unwrapIfNotMulti(

if (idProps.length !== 1) {
if (!idProps.length) {
const isStr = typeof spec.id === 'string';
msg =
'A nonexistent object was used in an `' +
depType +
'` of a Dash callback. The id of this object is ' +
(isStr
? '`' + spec.id + '`'
: JSON.stringify(spec.id) +
(anyVals ? ' with MATCH values ' + anyVals : '')) +
' and the property is `' +
spec.property +
(isStr
? '`. The string ids in the current layout are: [' +
keys(paths.strs).join(', ') +
']'
: '`. The wildcard ids currently available are logged above.');
if (spec.allow_optional) {
idProps = [{...spec, value: null}];
msg = '';
} else {
const isStr = typeof spec.id === 'string';
msg =
'A nonexistent object was used in an `' +
depType +
'` of a Dash callback. The id of this object is ' +
(isStr
? '`' + spec.id + '`'
: JSON.stringify(spec.id) +
(anyVals ? ' with MATCH values ' + anyVals : '')) +
' and the property is `' +
spec.property +
(isStr
? '`. The string ids in the current layout are: [' +
keys(paths.strs).join(', ') +
']'
: '`. The wildcard ids currently available are logged above.');
}
} else {
msg =
'Multiple objects were found for an `' +
Expand Down Expand Up @@ -203,7 +208,6 @@ function fillVals(
// That's a real problem, so throw the first message as an error.
refErr(errors, paths);
}

return inputVals;
}

Expand Down
25 changes: 24 additions & 1 deletion dash/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class DashDependency: # pylint: disable=too-few-public-methods
allow_duplicate: bool
component_property: str
allowed_wildcards: Sequence[_Wildcard]
allow_optional: bool

def __init__(self, component_id: ComponentIdType, component_property: str):

Expand All @@ -45,6 +46,7 @@ def __init__(self, component_id: ComponentIdType, component_property: str):

self.component_property = component_property
self.allow_duplicate = False
self.allow_optional = False

def __str__(self):
return f"{self.component_id_str()}.{self.component_property}"
Expand All @@ -56,7 +58,10 @@ def component_id_str(self) -> str:
return stringify_id(self.component_id)

def to_dict(self) -> dict:
return {"id": self.component_id_str(), "property": self.component_property}
specs = {"id": self.component_id_str(), "property": self.component_property}
if self.allow_optional:
specs["allow_optional"] = True
return specs

def __eq__(self, other):
"""
Expand Down Expand Up @@ -134,12 +139,30 @@ def __init__(
class Input(DashDependency): # pylint: disable=too-few-public-methods
"""Input of callback: trigger an update when it is updated."""

def __init__(
self,
component_id: ComponentIdType,
component_property: str,
allow_optional: bool = False,
):
super().__init__(component_id, component_property)
self.allow_optional = allow_optional

allowed_wildcards = (MATCH, ALL, ALLSMALLER)


class State(DashDependency): # pylint: disable=too-few-public-methods
"""Use the value of a State in a callback but don't trigger updates."""

def __init__(
self,
component_id: ComponentIdType,
component_property: str,
allow_optional: bool = False,
):
super().__init__(component_id, component_property)
self.allow_optional = allow_optional

allowed_wildcards = (MATCH, ALL, ALLSMALLER)


Expand Down
101 changes: 101 additions & 0 deletions tests/integration/callbacks/test_callback_optional.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from dash import Dash, html, Output, Input, State, no_update


def test_cbop001_optional_input(dash_duo):
app = Dash(suppress_callback_exceptions=True)

app.layout = html.Div(
[
html.Button(id="button1", children="Button 1"),
html.Div(id="button-container"),
html.Div(id="test-out"),
html.Div(id="test-out2"),
]
)

@app.callback(
Output("button-container", "children"),
Input("button1", "n_clicks"),
State("button-container", "children"),
prevent_initial_call=True,
)
def _(_, c):
if not c:
return html.Button(id="button2", children="Button 2")
return no_update

@app.callback(
Output("test-out", "children"),
Input("button1", "n_clicks"),
Input("button2", "n_clicks", allow_optional=True),
prevent_inital_call=True,
)
def display(n, n2):
return f"{n} - {n2}"

dash_duo.start_server(app)
dash_duo.wait_for_text_to_equal("#button1", "Button 1")
assert dash_duo.get_logs() == []
dash_duo.wait_for_text_to_equal("#test-out", "None - None")
dash_duo.find_element("#button1").click()
dash_duo.wait_for_text_to_equal("#test-out", "1 - None")

dash_duo.find_element("#button2").click()
dash_duo.wait_for_text_to_equal("#test-out", "1 - 1")
assert dash_duo.get_logs() == []


def test_cbop002_optional_state(dash_duo):
app = Dash(suppress_callback_exceptions=True)

app.layout = html.Div(
[
html.Button(id="button1", children="Button 1"),
html.Div(id="button-container"),
html.Div(id="test-out"),
html.Div(id="test-out2"),
]
)

@app.callback(
Output("button-container", "children"),
Input("button1", "n_clicks"),
State("button-container", "children"),
prevent_initial_call=True,
)
def _(_, c):
if not c:
return html.Button(id="button2", children="Button 2")
return no_update

@app.callback(
Output("test-out", "children"),
Input("button1", "n_clicks"),
State("button2", "n_clicks", allow_optional=True),
prevent_inital_call=True,
)
def display(n, n2):
return f"{n} - {n2}"

@app.callback(
Output("test-out2", "children"),
Input("button2", "n_clicks", allow_optional=True),
)
def test(n):
if n:
return n
return no_update

dash_duo.start_server(app)
dash_duo.wait_for_text_to_equal("#button1", "Button 1")
assert dash_duo.get_logs() == []
dash_duo.wait_for_text_to_equal("#test-out", "None - None")
dash_duo.find_element("#button1").click()
dash_duo.wait_for_text_to_equal("#test-out", "1 - None")

dash_duo.find_element("#button2").click()
dash_duo.wait_for_text_to_equal("#test-out2", "1")
dash_duo.wait_for_text_to_equal("#test-out", "1 - None")
dash_duo.find_element("#button1").click()
dash_duo.wait_for_text_to_equal("#test-out", "2 - 1")
assert dash_duo.get_logs() == []