diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1c77b3709..b123ad5869 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,11 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## Added
- [#3369](https://github.com/plotly/dash/pull/3369) Expose `dash.NoUpdate` type
+- [#3371](https://github.com/plotly/dash/pull/3371) Add devtool hook to add components to the devtool bar ui.
## Fixed
- [#3353](https://github.com/plotly/dash/pull/3353) Support pattern-matching/dict ids in `dcc.Loading` `target_components`
-
+- [#3371](https://github.com/plotly/dash/pull/3371) Fix allow_optional triggering a warning for not found input.
# [3.1.1] - 2025-06-29
diff --git a/dash/_hooks.py b/dash/_hooks.py
index 058f0f7e12..5be26d6584 100644
--- a/dash/_hooks.py
+++ b/dash/_hooks.py
@@ -46,6 +46,7 @@ def __init__(self) -> None:
"callback": [],
"index": [],
"custom_data": [],
+ "dev_tools": [],
}
self._js_dist = []
self._css_dist = []
@@ -216,6 +217,24 @@ def wrap(func: _t.Callable[[_t.Dict], _t.Any]):
return wrap
+ def devtool(self, namespace: str, component_type: str, props=None):
+ """
+ Add a component to be rendered inside the dev tools.
+
+ If it's a dash component, it can be used in callbacks provided
+ that it has an id and the dependency is set with allow_optional=True.
+
+ `props` can be a function, in which case it will be called before
+ sending the component to the frontend.
+ """
+ self._ns["dev_tools"].append(
+ {
+ "namespace": namespace,
+ "type": component_type,
+ "props": props or {},
+ }
+ )
+
hooks = _Hooks()
diff --git a/dash/dash-renderer/src/APIController.react.js b/dash/dash-renderer/src/APIController.react.js
index beb0bc7669..d83f7b7415 100644
--- a/dash/dash-renderer/src/APIController.react.js
+++ b/dash/dash-renderer/src/APIController.react.js
@@ -103,14 +103,14 @@ const UnconnectedContainer = props => {
content = (
<>
- {Array.isArray(layout) ? (
- layout.map((c, i) =>
+ {Array.isArray(layout.components) ? (
+ layout.components.map((c, i) =>
isSimpleComponent(c) ? (
c
) : (
)
@@ -118,7 +118,7 @@ const UnconnectedContainer = props => {
) : (
)}
>
@@ -153,7 +153,7 @@ function storeEffect(props, events, setErrorLoading) {
}
dispatch(apiThunk('_dash-layout', 'GET', 'layoutRequest'));
} else if (layoutRequest.status === STATUS.OK) {
- if (isEmpty(layout)) {
+ if (isEmpty(layout.components)) {
if (typeof hooks.layout_post === 'function') {
hooks.layout_post(layoutRequest.content);
}
@@ -163,7 +163,12 @@ function storeEffect(props, events, setErrorLoading) {
);
dispatch(
setPaths(
- computePaths(finalLayout, [], null, events.current)
+ computePaths(
+ finalLayout,
+ ['components'],
+ null,
+ events.current
+ )
)
);
dispatch(setLayout(finalLayout));
@@ -194,7 +199,7 @@ function storeEffect(props, events, setErrorLoading) {
!isEmpty(graphs) &&
// LayoutRequest and its computed stores
layoutRequest.status === STATUS.OK &&
- !isEmpty(layout) &&
+ !isEmpty(layout.components) &&
// Hasn't already hydrated
appLifecycle === getAppState('STARTED')
) {
diff --git a/dash/dash-renderer/src/actions/dependencies.js b/dash/dash-renderer/src/actions/dependencies.js
index 223b3f8c4c..63c9440fb2 100644
--- a/dash/dash-renderer/src/actions/dependencies.js
+++ b/dash/dash-renderer/src/actions/dependencies.js
@@ -5,6 +5,7 @@ import {
any,
ap,
assoc,
+ concat,
difference,
equals,
evolve,
@@ -568,10 +569,20 @@ export function validateCallbacksToLayout(state_, dispatchError) {
function validateMap(map, cls, doState) {
for (const id in map) {
const idProps = map[id];
+ const fcb = flatten(values(idProps));
+ const optional = all(
+ ({allow_optional}) => allow_optional,
+ flatten(
+ fcb.map(cb => concat(cb.outputs, cb.inputs, cb.states))
+ ).filter(dep => dep.id === id)
+ );
+ if (optional) {
+ continue;
+ }
const idPath = getPath(paths, id);
if (!idPath) {
if (validateIds) {
- missingId(id, cls, flatten(values(idProps)));
+ missingId(id, cls, fcb);
}
} else {
for (const property in idProps) {
diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js
index fd8c314d78..01dd67cf8a 100644
--- a/dash/dash-renderer/src/actions/index.js
+++ b/dash/dash-renderer/src/actions/index.js
@@ -89,7 +89,7 @@ function triggerDefaultState(dispatch, getState) {
dispatch(
addRequestedCallbacks(
- getLayoutCallbacks(graphs, paths, layout, {
+ getLayoutCallbacks(graphs, paths, layout.components, {
outputsOnly: true
})
)
diff --git a/dash/dash-renderer/src/components/error/menu/DebugMenu.react.js b/dash/dash-renderer/src/components/error/menu/DebugMenu.react.js
index e978ef1d4a..701812ab85 100644
--- a/dash/dash-renderer/src/components/error/menu/DebugMenu.react.js
+++ b/dash/dash-renderer/src/components/error/menu/DebugMenu.react.js
@@ -13,6 +13,8 @@ import Expand from '../icons/Expand.svg';
import {VersionInfo} from './VersionInfo.react';
import {CallbackGraphContainer} from '../CallbackGraph/CallbackGraphContainer.react';
import {FrontEndErrorContainer} from '../FrontEnd/FrontEndErrorContainer.react';
+import ExternalWrapper from '../../../wrapper/ExternalWrapper';
+import {useSelector} from 'react-redux';
const classes = (base, variant, variant2) =>
`${base} ${base}--${variant}` + (variant2 ? ` ${base}--${variant2}` : '');
@@ -35,6 +37,7 @@ const MenuContent = ({
toggleCallbackGraph,
config
}) => {
+ const ready = useSelector(state => state.appLifecycle === 'HYDRATED');
const _StatusIcon = hotReload
? connected
? CheckIcon
@@ -47,6 +50,25 @@ const MenuContent = ({
: 'unavailable'
: 'cold';
+ let custom = null;
+ if (config.dev_tools?.length && ready) {
+ custom = (
+ <>
+ {config.dev_tools.map((devtool, i) => (
+
+ ))}
+
+ >
+ );
+ }
+
return (
+ {custom}
);
};
diff --git a/dash/dash-renderer/src/reducers/layout.js b/dash/dash-renderer/src/reducers/layout.js
index e9c2aba0e7..6b58deabea 100644
--- a/dash/dash-renderer/src/reducers/layout.js
+++ b/dash/dash-renderer/src/reducers/layout.js
@@ -10,12 +10,15 @@ import {
import {getAction} from '../actions/constants';
-const layout = (state = {}, action) => {
+const layout = (state = {components: []}, action) => {
if (action.type === getAction('SET_LAYOUT')) {
if (Array.isArray(action.payload)) {
- return [...action.payload];
+ state.components = [...action.payload];
+ } else {
+ state.components = {...action.payload};
}
- return {...action.payload};
+
+ return state;
} else if (
includes(action.type, [
'UNDO_PROP_CHANGE',
diff --git a/dash/dash.py b/dash/dash.py
index 92c4a5d542..eab3e3358e 100644
--- a/dash/dash.py
+++ b/dash/dash.py
@@ -890,6 +890,16 @@ def _config(self):
config["validation_layout"] = validation_layout
+ if self._dev_tools.ui:
+ # Add custom dev tools hooks if the ui is activated.
+ custom_dev_tools = []
+ for hook_dev_tools in self._hooks.get_hooks("dev_tools"):
+ props = hook_dev_tools.get("props", {})
+ if callable(props):
+ props = props()
+ custom_dev_tools.append({**hook_dev_tools, "props": props})
+ config["dev_tools"] = custom_dev_tools
+
return config
def serve_reload_hash(self):
diff --git a/tests/async_tests/test_async_callbacks.py b/tests/async_tests/test_async_callbacks.py
index 8ad200f51b..13cb8418f9 100644
--- a/tests/async_tests/test_async_callbacks.py
+++ b/tests/async_tests/test_async_callbacks.py
@@ -119,9 +119,10 @@ async def update_input(value):
paths = dash_duo.redux_state_paths
assert paths["objs"] == {}
assert paths["strs"] == {
- "input": ["props", "children", 0],
- "output": ["props", "children", 1],
+ "input": ["components", "props", "children", 0],
+ "output": ["components", "props", "children", 1],
"sub-input-1": [
+ "components",
"props",
"children",
1,
@@ -132,6 +133,7 @@ async def update_input(value):
0,
],
"sub-output-1": [
+ "components",
"props",
"children",
1,
diff --git a/tests/integration/callbacks/state_path.json b/tests/integration/callbacks/state_path.json
index 94ca6b4dcd..cceb9ddfdc 100644
--- a/tests/integration/callbacks/state_path.json
+++ b/tests/integration/callbacks/state_path.json
@@ -2,9 +2,10 @@
"chapter1": {
"objs": {},
"strs": {
- "toc": ["props", "children", 0],
- "body": ["props", "children", 1],
+ "toc": ["components", "props", "children", 0],
+ "body": ["components", "props", "children", 1],
"chapter1-header": [
+ "components",
"props",
"children",
1,
@@ -15,6 +16,7 @@
0
],
"chapter1-controls": [
+ "components",
"props",
"children",
1,
@@ -25,6 +27,7 @@
1
],
"chapter1-label": [
+ "components",
"props",
"children",
1,
@@ -35,6 +38,7 @@
2
],
"chapter1-graph": [
+ "components",
"props",
"children",
1,
@@ -49,9 +53,10 @@
"chapter2": {
"objs": {},
"strs": {
- "toc": ["props", "children", 0],
- "body": ["props", "children", 1],
+ "toc": ["components", "props", "children", 0],
+ "body": ["components", "props", "children", 1],
"chapter2-header": [
+ "components",
"props",
"children",
1,
@@ -62,6 +67,7 @@
0
],
"chapter2-controls": [
+ "components",
"props",
"children",
1,
@@ -72,6 +78,7 @@
1
],
"chapter2-label": [
+ "components",
"props",
"children",
1,
@@ -82,6 +89,7 @@
2
],
"chapter2-graph": [
+ "components",
"props",
"children",
1,
@@ -96,9 +104,10 @@
"chapter3": {
"objs": {},
"strs": {
- "toc": ["props", "children", 0],
- "body": ["props", "children", 1],
+ "toc": ["components", "props", "children", 0],
+ "body": ["components", "props", "children", 1],
"chapter3-header": [
+ "components",
"props",
"children",
1,
@@ -112,6 +121,7 @@
0
],
"chapter3-label": [
+ "components",
"props",
"children",
1,
@@ -125,6 +135,7 @@
1
],
"chapter3-graph": [
+ "components",
"props",
"children",
1,
@@ -138,6 +149,7 @@
2
],
"chapter3-controls": [
+ "components",
"props",
"children",
1,
diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py
index 72af7d3cac..41e0e8592c 100644
--- a/tests/integration/callbacks/test_basic_callback.py
+++ b/tests/integration/callbacks/test_basic_callback.py
@@ -113,9 +113,10 @@ def update_input(value):
paths = dash_duo.redux_state_paths
assert paths["objs"] == {}
assert paths["strs"] == {
- "input": ["props", "children", 0],
- "output": ["props", "children", 1],
+ "input": ["components", "props", "children", 0],
+ "output": ["components", "props", "children", 1],
"sub-input-1": [
+ "components",
"props",
"children",
1,
@@ -126,6 +127,7 @@ def update_input(value):
0,
],
"sub-output-1": [
+ "components",
"props",
"children",
1,
diff --git a/tests/integration/callbacks/test_layout_paths_with_callbacks.py b/tests/integration/callbacks/test_layout_paths_with_callbacks.py
index ffa793a7a3..7ce0296bc4 100644
--- a/tests/integration/callbacks/test_layout_paths_with_callbacks.py
+++ b/tests/integration/callbacks/test_layout_paths_with_callbacks.py
@@ -37,8 +37,8 @@ def snapshot(name):
html.Div(id="body"),
]
)
- for script in dcc._js_dist:
- app.scripts.append_script(script)
+ # for script in dcc._js_dist:
+ # app.scripts.append_script(script)
chapters = {
"chapter1": html.Div(
@@ -183,7 +183,7 @@ def check_chapter(chapter):
)
== value
),
- TIMEOUT,
+ 20,
)
assert not dash_duo.redux_state_is_loading, "loadingMap is empty"
@@ -237,8 +237,8 @@ def check_call_counts(chapters, count):
), "each element should exist in the dom"
assert paths["strs"] == {
- "toc": ["props", "children", 0],
- "body": ["props", "children", 1],
+ "toc": ["components", "props", "children", 0],
+ "body": ["components", "props", "children", 1],
}
dash_duo.find_elements('input[type="radio"]')[0].click()
diff --git a/tests/integration/callbacks/test_missing_outputs.py b/tests/integration/callbacks/test_missing_outputs.py
index 07ea446611..a2ccca22fa 100644
--- a/tests/integration/callbacks/test_missing_outputs.py
+++ b/tests/integration/callbacks/test_missing_outputs.py
@@ -319,7 +319,9 @@ def chapter2_assertions():
dash_duo.wait_for_text_to_equal("#body", "Chapter 2")
layout = dash_duo.driver.execute_script(
- "return JSON.parse(JSON.stringify(" "window.store.getState().layout" "))"
+ "return JSON.parse(JSON.stringify("
+ "window.store.getState().layout"
+ ")).components"
)
dcc_radio = layout["props"]["children"][0]
diff --git a/tests/integration/renderer/test_due_diligence.py b/tests/integration/renderer/test_due_diligence.py
index 8c28edd853..9ff689a4f6 100644
--- a/tests/integration/renderer/test_due_diligence.py
+++ b/tests/integration/renderer/test_due_diligence.py
@@ -71,7 +71,7 @@ def test_rddd001_initial_state(dash_duo):
assert dash_duo.get_logs() == [], "Check that no errors or warnings were displayed"
assert dash_duo.driver.execute_script(
- "return JSON.parse(JSON.stringify(window.store.getState().layout))"
+ "return JSON.parse(JSON.stringify(window.store.getState().layout)).components"
) == json.loads(
json.dumps(app.layout, cls=plotly.utils.PlotlyJSONEncoder)
), "the state layout is identical to app.layout"
@@ -83,7 +83,8 @@ def test_rddd001_initial_state(dash_duo):
paths = dash_duo.redux_state_paths
assert paths["objs"] == {}
assert paths["strs"] == {
- abbr: [
+ abbr: ["components"]
+ + [
int(token)
if token in string.digits
else token.replace("p", "props").replace("c", "children")
diff --git a/tests/integration/test_hooks.py b/tests/integration/test_hooks.py
index cbb1f44551..ef6c0cc4a4 100644
--- a/tests/integration/test_hooks.py
+++ b/tests/integration/test_hooks.py
@@ -15,6 +15,7 @@ def hook_cleanup():
hooks._ns["callback"] = []
hooks._ns["index"] = []
hooks._ns["custom_data"] = []
+ hooks._ns["dev_tools"] = []
hooks._css_dist = []
hooks._js_dist = []
hooks._finals = {}
@@ -210,3 +211,32 @@ def cb(_):
dash_duo.start_server(app)
dash_duo.wait_for_element("#btn").click()
dash_duo.wait_for_text_to_equal("#output", "custom-data")
+
+
+def test_hook011_devtool_hook(hook_cleanup, dash_duo):
+ hooks.devtool(
+ "dash_html_components", "Button", {"children": "devtool", "id": "devtool"}
+ )
+
+ app = Dash()
+ app.layout = html.Div(["hooked", html.Div(id="output")])
+
+ @app.callback(
+ Output("output", "children"),
+ Input("devtool", "n_clicks", allow_optional=True),
+ prevent_initial_call=True,
+ )
+ def cb(_):
+ return "hooked from devtools"
+
+ dash_duo.start_server(
+ app,
+ debug=True,
+ use_reloader=False,
+ use_debugger=True,
+ dev_tools_hot_reload=False,
+ dev_tools_props_check=False,
+ dev_tools_disable_version_check=True,
+ )
+ dash_duo.wait_for_element("#devtool").click()
+ dash_duo.wait_for_text_to_equal("#output", "hooked from devtools")