diff --git a/CHANGELOG.md b/CHANGELOG.md index 072445db8a..69e237f176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3986](https://github.com/plotly/dash/pull/3986) Adjust `_run_before_hooks` in the `fastapi` backend to honor a response returned by a `before_request` function, matching the `flask` backend's behavior. ### Fixed +- [#4078](https://github.com/plotly/dash/pull/4078) Components are now always truthy, so `if component:` and `component or default` no longer treat a component with no children (or no `children` prop, like `DataTable`) as false. With `use_pages=True`, an empty layout such as `html.Div()` still counts as unset and is replaced by `page_container`. Fixes [#2906](https://github.com/plotly/dash/issues/2906). - [#4075](https://github.com/plotly/dash/pull/4075) `fastapi` backend: run `before_request` hooks on every route of the Dash app (assets, hook routes, `api_endpoint`, pages, when mounted) and send a non-None hook result as the response, as on Flask. `fastapi` and `quart` also run them on the WebSocket handshake. - [#4075](https://github.com/plotly/dash/pull/4075) Background callbacks without `cache_by` no longer share a result between requests with equal inputs (unread Diskcache results now expire after a day, or after `expire` when set, even with `cache_by`), and callbacks with the same source no longer share a cache key. `build_cache_key` takes a new `callback_key` argument. - [#4075](https://github.com/plotly/dash/pull/4075) `dcc.Location(refresh=True).href`, `html.Area.href` and `html.Base.href` now block `javascript:` URLs like `html.A`. diff --git a/dash/dash.py b/dash/dash.py index 8c3bf625ef..f9c0e28cc9 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -42,7 +42,7 @@ Output, State, ) -from .development.base_component import ComponentRegistry +from .development.base_component import Component, ComponentRegistry from .exceptions import ( ProxyError, DuplicateCallback, @@ -247,6 +247,17 @@ async def execute_async_function(func, *args, **kwargs): return func(*args, **kwargs) +def _layout_is_unset(layout): + """True when ``use_pages`` should fill in ``page_container`` for ``layout``. + + Components are always truthy, so an empty component such as + ``html.Div()`` is checked by its length to keep it counting as unset. + """ + if isinstance(layout, Component): + return len(layout) == 0 + return not layout + + # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-arguments, too-many-locals class Dash(ObsoleteChecker): @@ -1891,7 +1902,7 @@ def _setup_server(self): if self.config.include_assets_files: self._walk_assets_directory() - if not self.layout and self.use_pages: + if self.use_pages and _layout_is_unset(self.layout): self.layout = page_container _validate.validate_layout(self.layout, self._layout_value()) diff --git a/dash/development/base_component.py b/dash/development/base_component.py index b6798064e1..fb865312cd 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -433,6 +433,11 @@ def __iter__(self): for t in self._traverse_ids(): yield t.id # type: ignore[reportAttributeAccessIssue] + def __bool__(self): + # Without this, truthiness falls back to __len__, so a component + # with no children (or no children prop, like DataTable) is falsy. + return True + def __len__(self): """Return the number of items in the tree.""" # TODO - Should we return the number of items that have IDs diff --git a/tests/integration/multi_page/test_pages_layout.py b/tests/integration/multi_page/test_pages_layout.py index 004aa0f4d8..a9c2e4d765 100644 --- a/tests/integration/multi_page/test_pages_layout.py +++ b/tests/integration/multi_page/test_pages_layout.py @@ -295,3 +295,21 @@ def test_pala007_app_title_discription(dash_duo, clear_pages_state): assert dash.page_registry["page1"]["title"] == "Page 1 Title" assert dash.page_registry["home"]["description"] == "App Description" assert dash.page_registry["page1"]["description"] == "Page 1 Description" + + +def test_pala008_empty_layout_uses_page_container(dash_duo, clear_pages_state): + # An empty placeholder layout still counts as unset with use_pages, so + # Dash fills in page_container and the registered pages render. + # The pages folder has callbacks on ids that only exist in other tests' + # layouts, so callback exceptions are suppressed here. + app = Dash(__name__, use_pages=True, suppress_callback_exceptions=True) + dash.register_page( + "empty_layout_home", + layout=html.Div("Home for empty layout", id="contents"), + path="/", + ) + app.layout = html.Div() + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#contents", "Home for empty layout") + assert dash_duo.get_logs() == [] diff --git a/tests/unit/development/test_base_component.py b/tests/unit/development/test_base_component.py index cefde04fde..a7ba6a2720 100644 --- a/tests/unit/development/test_base_component.py +++ b/tests/unit/development/test_base_component.py @@ -6,7 +6,7 @@ from dash import __version__, Dash from dash import html from dash.development.base_component import Component -from dash import dcc, Input, Output +from dash import dcc, dash_table, Input, Output Component._prop_names = ("id", "a", "children", "style") Component._type = "TestComponent" @@ -539,3 +539,11 @@ def update2(v): def test_debc030_invalid_children_args(): with pytest.raises(TypeError): dcc.Input(children="invalid children") + + +def test_debc031_bool_is_always_true(): + assert bool(Component()) + assert bool(Component(children=[])) + assert bool(html.Div()) + assert bool(dash_table.DataTable(data=[{"a": 1}])) + assert (html.Div() or None) is not None