diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a1e4c17b..c61ff91f77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#2610](https://github.com/plotly/dash/pull/2610) Load plotly.js bundle/version from plotly.py +## Added + +- [#2630](https://github.com/plotly/dash/pull/2630) New layout hooks in the renderer + + ## [2.12.1] - 2023-08-16 ## Fixed diff --git a/dash/_utils.py b/dash/_utils.py index bd1b7a21b4..d577c87997 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -12,6 +12,8 @@ import string from html import escape from functools import wraps +from typing import Union +from dash.types import RendererHooks logger = logging.getLogger() @@ -267,3 +269,11 @@ def coerce_to_list(obj): def clean_property_name(name: str): return name.split("@")[0] + + +def hooks_to_js_object(hooks: Union[RendererHooks, None]) -> str: + if hooks is None: + return "" + hook_str = ",".join(f"{key}: {val}" for key, val in hooks.items()) + + return f"{{{hook_str}}}" diff --git a/dash/dash-renderer/src/APIController.react.js b/dash/dash-renderer/src/APIController.react.js index f27a48a90a..589821c80b 100644 --- a/dash/dash-renderer/src/APIController.react.js +++ b/dash/dash-renderer/src/APIController.react.js @@ -131,14 +131,21 @@ function storeEffect(props, events, setErrorLoading) { dispatch, error, graphs, + hooks, layout, layoutRequest } = props; if (isEmpty(layoutRequest)) { + if (typeof hooks.layout_pre === 'function') { + hooks.layout_pre(); + } dispatch(apiThunk('_dash-layout', 'GET', 'layoutRequest')); } else if (layoutRequest.status === STATUS.OK) { if (isEmpty(layout)) { + if (typeof hooks.layout_post === 'function') { + hooks.layout_post(layoutRequest.content); + } const finalLayout = applyPersistence( layoutRequest.content, dispatch @@ -198,6 +205,7 @@ UnconnectedContainer.propTypes = { dispatch: PropTypes.func, dependenciesRequest: PropTypes.object, graphs: PropTypes.object, + hooks: PropTypes.object, layoutRequest: PropTypes.object, layout: PropTypes.object, loadingMap: PropTypes.any, @@ -211,6 +219,7 @@ const Container = connect( state => ({ appLifecycle: state.appLifecycle, dependenciesRequest: state.dependenciesRequest, + hooks: state.hooks, layoutRequest: state.layoutRequest, layout: state.layout, loadingMap: state.loadingMap, diff --git a/dash/dash-renderer/src/AppContainer.react.js b/dash/dash-renderer/src/AppContainer.react.js index 85ae475578..56573773d6 100644 --- a/dash/dash-renderer/src/AppContainer.react.js +++ b/dash/dash-renderer/src/AppContainer.react.js @@ -13,6 +13,8 @@ class UnconnectedAppContainer extends React.Component { constructor(props) { super(props); if ( + props.hooks.layout_pre !== null || + props.hooks.layout_post !== null || props.hooks.request_pre !== null || props.hooks.request_post !== null || props.hooks.callback_resolved !== null || diff --git a/dash/dash-renderer/src/AppProvider.react.tsx b/dash/dash-renderer/src/AppProvider.react.tsx index aaf09d59e4..88438067b0 100644 --- a/dash/dash-renderer/src/AppProvider.react.tsx +++ b/dash/dash-renderer/src/AppProvider.react.tsx @@ -16,6 +16,8 @@ const AppProvider = ({hooks}: any) => { AppProvider.propTypes = { hooks: PropTypes.shape({ + layout_pre: PropTypes.func, + layout_post: PropTypes.func, request_pre: PropTypes.func, request_post: PropTypes.func, callback_resolved: PropTypes.func, @@ -25,6 +27,8 @@ AppProvider.propTypes = { AppProvider.defaultProps = { hooks: { + layout_pre: null, + layout_post: null, request_pre: null, request_post: null, callback_resolved: null, diff --git a/dash/dash-renderer/src/reducers/hooks.js b/dash/dash-renderer/src/reducers/hooks.js index 83370efb71..176151c0a4 100644 --- a/dash/dash-renderer/src/reducers/hooks.js +++ b/dash/dash-renderer/src/reducers/hooks.js @@ -1,5 +1,7 @@ const customHooks = ( state = { + layout_pre: null, + layout_post: null, request_pre: null, request_post: null, callback_resolved: null, diff --git a/dash/dash.py b/dash/dash.py index 22bc7f74bd..53fa00ec2e 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -16,6 +16,7 @@ import base64 import traceback from urllib.parse import urlparse +from typing import Union import flask @@ -52,6 +53,7 @@ to_json, convert_to_AttributeDict, gen_salt, + hooks_to_js_object, ) from . import _callback from . import _get_paths @@ -70,6 +72,7 @@ _import_layouts_from_pages, ) from ._jupyter import jupyter_dash, JupyterDisplayMode +from .types import RendererHooks # Add explicit mapping for map files mimetypes.add_type("application/json", ".map", True) @@ -134,7 +137,6 @@ def _get_traceback(secret, error: Exception): - try: # pylint: disable=import-outside-toplevel from werkzeug.debug import tbtools @@ -339,6 +341,10 @@ class Dash: :param add_log_handler: Automatically add a StreamHandler to the app logger if not added previously. + + :param hooks: Extend Dash renderer functionality by passing a dictionary of + javascript functions. To hook into the layout, use dict keys "layout_pre" and + "layout_post". To hook into the callbacks, use keys "request_pre" and "request_post" """ _plotlyjs_url: str @@ -375,6 +381,7 @@ def __init__( # pylint: disable=too-many-statements long_callback_manager=None, background_callback_manager=None, add_log_handler=True, + hooks: Union[RendererHooks, None] = None, **obsolete, ): _validate.check_obsolete(obsolete) @@ -468,7 +475,7 @@ def __init__( # pylint: disable=too-many-statements self._favicon = None # default renderer string - self.renderer = "var renderer = new DashRenderer();" + self.renderer = f"var renderer = new DashRenderer({hooks_to_js_object(hooks)});" # static files from the packages self.css = Css(serve_locally) @@ -1327,7 +1334,6 @@ def _setup_server(self): # Copy over global callback data structures assigned with `dash.callback` for k in list(_callback.GLOBAL_CALLBACK_MAP): - if k in self.callback_map: raise DuplicateCallback( f"The callback `{k}` provided with `dash.callback` was already " @@ -1354,7 +1360,6 @@ def _setup_server(self): if cancels: for cancel_input, manager in cancels.items(): - # pylint: disable=cell-var-from-loop @self.callback( Output(cancel_input.component_id, "id"), @@ -1745,7 +1750,6 @@ def enable_dev_tools( _reload.watch_thread.start() if debug: - if jupyter_dash.active: jupyter_dash.configure_callback_exception_handling( self, dev_tools.prune_errors @@ -1779,7 +1783,6 @@ def _after_request(response): dash_total["dur"] = round((time.time() - dash_total["dur"]) * 1000) for name, info in timing_information.items(): - value = name if info.get("desc") is not None: value += f';desc="{info["desc"]}"' diff --git a/dash/types.py b/dash/types.py new file mode 100644 index 0000000000..c954f654bb --- /dev/null +++ b/dash/types.py @@ -0,0 +1,10 @@ +from typing_extensions import TypedDict, NotRequired + + +class RendererHooks(TypedDict): + layout_pre: NotRequired[str] + layout_post: NotRequired[str] + request_pre: NotRequired[str] + request_post: NotRequired[str] + callback_resolved: NotRequired[str] + request_refresh_jwt: NotRequired[str] diff --git a/tests/integration/renderer/test_request_hooks.py b/tests/integration/renderer/test_request_hooks.py index ea2bede020..9c4febec12 100644 --- a/tests/integration/renderer/test_request_hooks.py +++ b/tests/integration/renderer/test_request_hooks.py @@ -4,6 +4,7 @@ import pytest from dash import Dash, Output, Input, html, dcc +from dash.types import RendererHooks from werkzeug.exceptions import HTTPException @@ -115,7 +116,6 @@ def update_output(value): def test_rdrh002_with_custom_renderer_interpolated(dash_duo): - renderer = """