Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added
- New dev tools option `dev_tools_hot_reload_preserve_state` (env: `DASH_HOT_RELOAD_PRESERVE_STATE`, off by default): preserve UI state across hot reloads. Prop values edited in the browser (input values, dropdown selections, active tab...), props set through `set_props` (serverside or clientside), and memory-type `dcc.Store` data are saved right before a hot reload and re-applied afterward, unless the prop's initial value changed in the reloaded code - then the new code wins. Works for soft and hard reloads and for components created by callbacks (e.g. `pages` content). A manual browser refresh still resets the app, and the saved state is scoped per app (by a persisted per-app `end_id`) so switching to a different app served on the same URL never restores another app's state.
- [#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).
Expand Down
1 change: 1 addition & 0 deletions dash/_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def load_dash_env_vars():
"DASH_HOT_RELOAD_INTERVAL",
"DASH_HOT_RELOAD_WATCH_INTERVAL",
"DASH_HOT_RELOAD_MAX_RETRY",
"DASH_HOT_RELOAD_PRESERVE_STATE",
"DASH_SILENCE_ROUTES_LOGGING",
"DASH_DISABLE_VERSION_CHECK",
"DASH_PRUNE_ERRORS",
Expand Down
78 changes: 78 additions & 0 deletions dash/_hot_reload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Persistence for the hot-reload state-preservation token.

When ``dev_tools_hot_reload_preserve_state`` is on, the renderer scopes the
sessionStorage snapshot of preserved UI state by the page's ``end_id`` (see
``dash/_callback_signing.py``). A hard hot reload restarts the server process
and re-serves the page, so that token has to survive the restart *and* stay
unique to this app - otherwise switching to a different app served on the same
URL (same ``window.location.pathname``) would let one app's snapshot be
restored into another's, re-firing its callbacks with foreign state.

We get both by persisting the token to disk keyed by the app's path: the same
app reads back the same token across reloads, a different app (different path)
gets a different one and so a different sessionStorage scope. This is a
dev-only convenience, so a missing/unwritable cache dir degrades gracefully to
an in-process token (state is then preserved across soft reloads only).
"""

import hashlib
import os
import tempfile

_SUBDIR = "hot_reload_state"


def _base_dir():
"""A per-user writable directory for the persisted tokens.

Prefer ``platformdirs`` when it is importable (it picks the right per-OS
location), but never hard-depend on it - fall back to ``~/.dash`` and then
the system temp dir so this keeps working in a bare install.
"""
try:
import platformdirs # pylint: disable=import-outside-toplevel

return platformdirs.user_data_dir("dash", "plotly")
except Exception: # pylint: disable=broad-except
pass
try:
home = os.path.expanduser("~")
if home and home != "~":
return os.path.join(home, ".dash")
except Exception: # pylint: disable=broad-except
pass
return os.path.join(tempfile.gettempdir(), "dash")


def _token_path(app_key):
# Hash the app key so an arbitrary filesystem path becomes a safe,
# fixed-length filename.
digest = hashlib.sha256(app_key.encode("utf-8")).hexdigest()[:16]
return os.path.join(_base_dir(), _SUBDIR, f"{digest}.txt")


def stable_end_id(app_key, factory):
"""Return a token stable across reloads of the app identified by ``app_key``.

Reads the persisted token for ``app_key`` if one exists, otherwise calls
``factory()`` to mint a fresh one and persists it. Any disk error falls
back to the freshly minted token without persisting, so hot reload still
works (state preserved across soft reloads only).
"""
path = _token_path(app_key)
try:
with open(path, encoding="utf-8") as handle:
existing = handle.read().strip()
if existing:
return existing
except OSError:
pass

token = factory()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as handle:
handle.write(token)
except OSError:
pass
return token
7 changes: 6 additions & 1 deletion dash/dash-renderer/src/APIController.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import apiThunk from './actions/api';
import {EventEmitter} from './actions/utils';
import {applyPersistence} from './persistence';
import {applyReloadState} from './reloadState';
import {getAppState} from './reducers/constants';
import {STATUS} from './constants/constants';
import wait from './utils/wait';
Expand Down Expand Up @@ -158,10 +159,14 @@
if (typeof hooks.layout_post === 'function') {
hooks.layout_post(layoutRequest.content);
}
const finalLayout = applyPersistence(
let finalLayout = applyPersistence(
layoutRequest.content,
dispatch
);
if (config.hot_reload && config.hot_reload.preserve_state) {

Check warning on line 166 in dash/dash-renderer/src/APIController.react.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaBEWT0FxrkN3aRcdUCG&open=AaBEWT0FxrkN3aRcdUCG&pullRequest=3896
// Restore UI state saved just before a hot reload.
finalLayout = applyReloadState(finalLayout, config.end_id);
}
dispatch(
setPaths(
computePaths(
Expand Down
24 changes: 18 additions & 6 deletions dash/dash-renderer/src/actions/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,12 @@ async function handleClientside(
return result;
}

function updateComponent(component_id: any, props: any, cb: ICallbackPayload) {
function updateComponent(
component_id: any,
props: any,
cb: ICallbackPayload,
recordState = false
) {
return function (dispatch: any, getState: any) {
const {paths, config} = getState();
const componentPath = getPath(paths, component_id);
Expand All @@ -422,7 +427,8 @@ function updateComponent(component_id: any, props: any, cb: ICallbackPayload) {
updateProps({
props,
itempath: componentPath,
renderType: 'callback'
renderType: 'callback',
recordState
})
);
dispatch(notifyObservers({id: component_id, props}));
Expand All @@ -436,7 +442,13 @@ function updateComponent(component_id: any, props: any, cb: ICallbackPayload) {
* @param cb The originating callback info.
* @returns
*/
function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) {
function sideUpdate(
outputs: SideUpdateOutput,
cb: ICallbackPayload,
// true for `set_props` payloads - persistent state the user asked for,
// as opposed to transient `running`/`progress` updates.
recordState = false
) {
return function (dispatch: any, getState: any) {
toPairs(outputs)
.reduce((acc, [id, value], i) => {
Expand Down Expand Up @@ -478,7 +490,7 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) {

const patchedProps = parsePatchProps(idProps, oldProps);

dispatch(updateComponent(id, patchedProps, cb));
dispatch(updateComponent(id, patchedProps, cb, recordState));

if (!componentPath) {
// Component doesn't exist, doesn't matter just allow the
Expand Down Expand Up @@ -702,7 +714,7 @@ function handleServerside(
}

if (data.sideUpdate) {
dispatch(sideUpdate(data.sideUpdate, payload));
dispatch(sideUpdate(data.sideUpdate, payload, true));
}

if (data.progress) {
Expand Down Expand Up @@ -834,7 +846,7 @@ async function handleWebsocketCallback(

// Handle sideUpdate if present
if (callbackData?.sideUpdate) {
dispatch(sideUpdate(callbackData.sideUpdate, payload));
dispatch(sideUpdate(callbackData.sideUpdate, payload, true));
}

// Extract the actual outputs from the response
Expand Down
10 changes: 9 additions & 1 deletion dash/dash-renderer/src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from './dependencies_ts';
import {computePaths, getPath} from './paths';
import {recordUiEdit} from '../persistence';
import {recordReloadEdit, shouldRecordReloadEdit} from '../reloadState';

export const onError = createAction(getAction('ON_ERROR'));
export const setAppLifecycle = createAction(getAction('SET_APP_LIFECYCLE'));
Expand All @@ -33,8 +34,15 @@ export const resetComponentState = createAction(

export function updateProps(payload) {
return (dispatch, getState) => {
const component = path(payload.itempath, getState().layout);
const {layout, config} = getState();
const component = path(payload.itempath, layout);
recordUiEdit(component, payload.props, dispatch);
if (
path(['hot_reload', 'preserve_state'], config) &&
shouldRecordReloadEdit(component, payload)
) {
recordReloadEdit(component, payload.props);
}
dispatch(onPropChange(payload));
};
}
Expand Down
7 changes: 7 additions & 0 deletions dash/dash-renderer/src/components/core/Reloader.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import apiThunk from '../../actions/api';
import {snapshotReloadState} from '../../reloadState';

class Reloader extends React.Component {
constructor(props) {
Expand Down Expand Up @@ -148,10 +149,16 @@ class Reloader extends React.Component {
// Assets file have changed
// or a component lib has been added/removed -
// Must do a hard reload
if (this.props.config.hot_reload.preserve_state) {
snapshotReloadState(this.props.config.end_id);
}
window.location.reload();
}
} else {
// Backend code changed - can do a soft reload in place
if (this.props.config.hot_reload.preserve_state) {
snapshotReloadState(this.props.config.end_id);
}
dispatch({type: 'RELOAD'});
}
} else if (
Expand Down
17 changes: 16 additions & 1 deletion dash/dash-renderer/src/observers/executedCallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from '../actions/patchAnalysis';

import {applyPersistence, prunePersistence} from '../persistence';
import {applyReloadState} from '../reloadState';
import {IStoreObserverDefinition} from '../StoreObserver';

const observer: IStoreObserverDefinition<IStoreState> = {
Expand Down Expand Up @@ -83,11 +84,25 @@ const observer: IStoreObserverDefinition<IStoreState> = {
// restored (e.g. after a component moves on page).
// Only the `children` prop matters here, that is the one
// applyPersistence recurses through
const {props} = applyPersistence(
let {props} = applyPersistence(
{props: updatedProps},
dispatch,
analysisForProp(patchAnalysis, 'children')
);
if (
pathOr(
false,
['config', 'hot_reload', 'preserve_state'],
getState()
)
) {
// Restore UI state saved just before a hot reload to
// components inserted by callbacks (e.g. pages content).
({props} = applyReloadState(
{props},
pathOr(undefined, ['config', 'end_id'], getState())
));
}
(dispatch as ThunkDispatch<any, any, AnyAction>)(
updateProps({
itempath,
Expand Down
3 changes: 2 additions & 1 deletion dash/dash-renderer/src/observers/websocketObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ export async function initializeWebSocket(
updateProps({
props: processedProps,
itempath: componentPath,
renderType: 'websocket'
renderType: 'websocket',
recordState: true
}) as any
);

Expand Down
Loading
Loading