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.
- [#3977](https://github.com/plotly/dash/pull/3977) Add partial WebSocket prop reads with `get_prop(..., path=...)`. Closes [#3975](https://github.com/plotly/dash/issues/3975).
- [#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.
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
8 changes: 8 additions & 0 deletions dash/dash-renderer/src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
} 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,6 +34,7 @@

export function updateProps(payload) {
return (dispatch, getState) => {
const {layout, config} = getState();

Check warning on line 37 in dash/dash-renderer/src/actions/index.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless assignment to variable "layout".

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaCRapu74LrnM-3CAaiY&open=AaCRapu74LrnM-3CAaiY&pullRequest=3896

Check warning on line 37 in dash/dash-renderer/src/actions/index.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the declaration of the unused 'layout' variable.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaCRapu74LrnM-3CAaiX&open=AaCRapu74LrnM-3CAaiX&pullRequest=3896

Check failure on line 37 in dash/dash-renderer/src/actions/index.js

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.9)

'layout' is assigned a value but never used

Check failure on line 37 in dash/dash-renderer/src/actions/index.js

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.12)

'layout' is assigned a value but never used
const component = path(payload.itempath, getState().layout);
// The component may no longer exist at this path - eg. an
// `ExternalWrapper` (components as props) whose host subtree was
Expand All @@ -42,6 +44,12 @@
return;
}
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 @@ -45,6 +45,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 @@ -84,11 +85,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 @@ -135,7 +135,8 @@
updateProps({
props: processedProps,
itempath: componentPath,
renderType: 'websocket'
renderType: 'websocket',
recordState: true
}) as any
);

Expand Down Expand Up @@ -289,9 +290,9 @@
try {
// config.websocket is guaranteed to exist due to wsAvailable check above
await workerClient.connect(
config.websocket!.worker_url,

Check warning on line 293 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.9)

Forbidden non-null assertion

Check warning on line 293 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.12)

Forbidden non-null assertion
wsUrl,
config.websocket!.inactivity_timeout

Check warning on line 295 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.9)

Forbidden non-null assertion

Check warning on line 295 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.12)

Forbidden non-null assertion
);
} catch (error) {
console.error('[Dash] Failed to connect to WebSocket worker:', error);
Expand Down
Loading
Loading