diff --git a/CHANGELOG.md b/CHANGELOG.md index 6823eab6cf..bf80c06709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- [#3976](https://github.com/plotly/dash/pull/3976) Add a new `scrollToTop` prop to `dcc.Link` to control whether the page scrolls to the top after client-side navigation. It defaults to `True` to preserve the existing behavior. Fixes [#3974](https://github.com/plotly/dash/issues/3974). - [#3765](https://github.com/plotly/dash/pull/3765) Add opt-in partial pattern matching for callback `Input`, `Output`, and `State` dependencies via `partial_pattern=True`. Dictionary ID patterns can now match component IDs containing additional keys, and partial patterns can be combined with `ALL` and `MATCH` wildcards. Fixes [#3764](https://github.com/plotly/dash/issues/3764). - [#3646](https://github.com/plotly/dash/pull/3646) Experimental support for React 19. The default is still React 18.3.1; to use React 19 set the environment variable `REACT_VERSION=19.2.4` before running your app, or call `dash._dash_renderer._set_react_version("19.2.4")` inside the app. React 19 has no official UMD builds, so Dash serves the [`umd-react`](https://www.npmjs.com/package/umd-react) package, together with a compatibility shim loaded after react-dom and before any component package. The shim keeps component libraries built against React <=18 (e.g. dash-bootstrap-components, dash-mantine-components) working under React 19: it stubs the removed `ReactCurrentOwner` internals, redirects the legacy element `$$typeof` symbol so pre-bundled React 18 jsx-runtimes produce elements React 19 accepts (error #525), and exposes a global `react/jsx-runtime` (`window.ReactJSXRuntime`) that Dash's own component bundles externalize to. Component library authors adopting this convention should copy the defensive `jsxRuntimeExternal` webpack external from `components/dash-core-components/webpack.config.js` rather than a bare `'ReactJSXRuntime'` string: it falls back to a `React.createElement`-based runtime when the global is missing, so the same build also works on Dash versions older than this release. - [#3925](https://github.com/plotly/dash/pull/3925) Add optional callback request payload compression for server-side callbacks via `compress_payload` and `compress_threshold` callback parameters (default threshold: 5,000 bytes). When enabled and the request body exceeds the threshold, the renderer sends gzip-compressed binary payloads with `Content-Encoding: gzip`, and Dash transparently decompresses on the server (Flask, FastAPI, and Quart). This can significantly reduce callback roundtrip times for large client-to-server payloads. Fixes [#3924](https://github.com/plotly/dash/issues/3924). diff --git a/components/dash-core-components/src/components/Link.tsx b/components/dash-core-components/src/components/Link.tsx index af727399be..d6726ab400 100644 --- a/components/dash-core-components/src/components/Link.tsx +++ b/components/dash-core-components/src/components/Link.tsx @@ -14,7 +14,11 @@ type LinkComponentProps = LinkProps & { * For links with destinations outside the current app, `html.A` is a better * component to use. */ -const Link = ({refresh = false, ...props}: LinkComponentProps) => { +const Link = ({ + refresh = false, + scrollToTop = true, + ...props +}: LinkComponentProps) => { const {className, style, id, href, children, title, target, setProps} = props; const cleanUrl = window.dash_clientside.clean_url; @@ -39,8 +43,9 @@ const Link = ({refresh = false, ...props}: LinkComponentProps) => { window.history.pushState({}, '', sanitizedUrl); window.dispatchEvent(new CustomEvent('_dashprivate_pushstate')); } - // scroll back to top - window.scrollTo(0, 0); + if (scrollToTop) { + window.scrollTo(0, 0); + } }; useEffect(() => { diff --git a/components/dash-core-components/src/types.ts b/components/dash-core-components/src/types.ts index da6d6def97..389f8246bc 100644 --- a/components/dash-core-components/src/types.ts +++ b/components/dash-core-components/src/types.ts @@ -1514,6 +1514,12 @@ export interface LinkProps { */ refresh?: boolean; + /** + * Controls whether or not the page will scroll to the top when the link is + * clicked. Defaults to true. + */ + scrollToTop?: boolean; + /** * Adds the title attribute to your link, which can contain supplementary * information. diff --git a/components/dash-core-components/tests/integration/link/test_scroll_to_top.py b/components/dash-core-components/tests/integration/link/test_scroll_to_top.py new file mode 100644 index 0000000000..3521f48242 --- /dev/null +++ b/components/dash-core-components/tests/integration/link/test_scroll_to_top.py @@ -0,0 +1,70 @@ +import pytest + +from dash import Dash, dcc, html +from dash.testing.wait import until + + +@pytest.mark.parametrize( + "link_props,scrolls_to_top", + [({}, True), ({"scrollToTop": False}, False)], + ids=["default", "disabled"], +) +def test_lisc001_scroll_to_top(dash_dcc, link_props, scrolls_to_top): + behavior = "enabled (default)" if scrolls_to_top else "disabled" + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div( + [ + html.H1("TOP OF PAGE", id="top-marker"), + html.P("The default Link behavior returns here after a click."), + ], + id="top-section", + style={ + "height": "100vh", + "padding": "1rem", + "boxSizing": "border-box", + }, + ), + html.Div( + [ + html.H1("BOTTOM OF PAGE", id="bottom-marker"), + html.P(f"scrollToTop is {behavior}."), + dcc.Link( + "Click to navigate", + href="/test-link", + id="test-link", + **link_props, + ), + ], + id="bottom-section", + style={ + "height": "100vh", + "padding": "1rem", + "boxSizing": "border-box", + "borderTop": "1px solid", + }, + ), + ] + ) + + dash_dcc.start_server(app) + + test_link = dash_dcc.wait_for_element("#test-link") + dash_dcc.driver.execute_script( + "document.getElementById('bottom-section').scrollIntoView()" + ) + until(lambda: dash_dcc.driver.execute_script("return window.scrollY") > 0, 3) + initial_scroll_position = dash_dcc.driver.execute_script("return window.scrollY") + + test_link.click() + + until(lambda: dash_dcc.driver.current_url.endswith("/test-link"), 3) + expected_scroll_position = 0 if scrolls_to_top else initial_scroll_position + until( + lambda: dash_dcc.driver.execute_script("return window.scrollY") + == expected_scroll_position, + 3, + ) + + assert dash_dcc.get_logs() == []