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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
All notable changes to `dash` will be documented in this file.
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.

## [4.4.0] - 2026-07-03

### Added
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
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=AZ9bnDqLWw4wwGCXQyRV&open=AZ9bnDqLWw4wwGCXQyRV&pullRequest=3896
// Restore UI state saved just before a hot reload.
finalLayout = applyReloadState(finalLayout);
}
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 @@ -352,7 +352,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 @@ -379,7 +384,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 @@ -393,7 +399,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 @@ -435,7 +447,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 @@ -628,7 +640,7 @@ function handleServerside(
}

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

if (data.progress) {
Expand Down Expand Up @@ -760,7 +772,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();
}
window.location.reload();
}
} else {
// Backend code changed - can do a soft reload in place
if (this.props.config.hot_reload.preserve_state) {
snapshotReloadState();
}
dispatch({type: 'RELOAD'});
}
} else if (
Expand Down
14 changes: 13 additions & 1 deletion dash/dash-renderer/src/observers/executedCallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {updateProps, setPaths, handleAsyncError} from '../actions';
import {getPath, computePaths} from '../actions/paths';

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

const observer: IStoreObserverDefinition<IStoreState> = {
Expand All @@ -65,7 +66,18 @@ const observer: IStoreObserverDefinition<IStoreState> = {

// In case the update contains whole components, see if any of
// those components have props to update to persist user edits.
const {props} = applyPersistence({props: updatedProps}, dispatch);
let {props} = applyPersistence({props: updatedProps}, dispatch);
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}));
}
(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 @@ -104,7 +104,8 @@
updateProps({
props: processedProps,
itempath: componentPath,
renderType: 'websocket'
renderType: 'websocket',
recordState: true
}) as any
);

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

Check warning on line 240 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

Check warning on line 240 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
wsUrl,
config.websocket!.inactivity_timeout

Check warning on line 242 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

Check warning on line 242 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
);
} catch (error) {
console.error('[Dash] Failed to connect to WebSocket worker:', error);
Expand Down
191 changes: 191 additions & 0 deletions dash/dash-renderer/src/reloadState.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* State preservation across dev-mode hot reloads.
*
* When `dev_tools_hot_reload_preserve_state` is enabled, UI-driven prop
* edits (dispatched through `updateProps` with renderType 'internal') are
* recorded in memory as [newVal, originalVal] pairs per `id.prop`. Right
* before the Reloader triggers a reload - a soft `RELOAD` dispatch or a
* full page reload - the record is written to sessionStorage. When the
* fresh layout arrives, each recorded value is re-applied only if the
* incoming initial value still equals `originalVal`: if the reloaded code
* changed a prop's initial value, the new code wins.
*
* Unlike `persistence` this is dev-only, applies to all components with an
* id regardless of their persistence props, and each saved edit is applied
* at most once. Entries that don't match anything in the initial layout
* stay pending so they can be applied to layout chunks inserted by initial
* callbacks (e.g. `pages` content). A manual browser refresh never
* restores state: the snapshot is only written when a hot reload fires,
* and is deleted from sessionStorage as soon as it's read back.
*/

import {equals, isEmpty, lensPath, set} from 'ramda';

import {crawlLayout} from './actions/utils';
import {stringifyId} from './actions/dependencies';

const storeKey = () => `_dash_reload_state.${window.location.pathname}`;

// Values are stored stringified so `undefined` (prop not present) survives
// the sessionStorage round-trip distinctly from `null`.
const UNDEFINED = 'U';
const _stringify = val => (val === undefined ? UNDEFINED : JSON.stringify(val));
const _parse = val => (val === UNDEFINED ? undefined : JSON.parse(val || null));

// UI edits made since (re)hydration: {idStr: {propName: [newVal, originalVal]}}
// holding live values.
let uiEdits = {};
// Edits recovered from the snapshot, awaiting a matching component:
// {idStr: {propName: [stringifiedNewVal, stringifiedOriginalVal]}}.
// Entries are removed as they are applied or invalidated.
let pending = null;

// Test hook: forget all recorded and pending edits, as if the js context
// was freshly created.
export function resetReloadState() {
uiEdits = {};
pending = null;
}

// UI edits and clientside `set_props` calls are state worth preserving;
// regular callback outputs are recomputed by the initial callbacks
// re-firing after the reload. Server-side `set_props` payloads arrive as
// renderType 'callback'/'websocket' but flag themselves with
// `recordState` (transient `running`/`progress` updates don't).
const RECORDED_RENDER_TYPES = ['internal', 'clientsideApi'];

Check warning on line 55 in dash/dash-renderer/src/reloadState.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`RECORDED_RENDER_TYPES` should be a `Set`, and use `RECORDED_RENDER_TYPES.has()` to check existence or non-existence.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ9bnDlsWw4wwGCXQyRN&open=AZ9bnDlsWw4wwGCXQyRN&pullRequest=3896

export function shouldRecordReloadEdit(component, {renderType, recordState}) {
if (recordState || RECORDED_RENDER_TYPES.includes(renderType)) {
return true;
}
// Memory-type dcc.Store data lives only in the layout, so unlike
// local/session stores it would be lost on reload - record writes to
// it no matter where they come from.
return Boolean(
component &&
component.type === 'Store' &&

Check warning on line 66 in dash/dash-renderer/src/reloadState.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=AZ9bnDlsWw4wwGCXQyRO&open=AZ9bnDlsWw4wwGCXQyRO&pullRequest=3896
component.namespace === 'dash_core_components' &&
(component.props.storage_type || 'memory') === 'memory'
);
}

export function recordReloadEdit(component, newProps) {
const id = component && component.props && component.props.id;

Check warning on line 73 in dash/dash-renderer/src/reloadState.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=AZ9bnDlsWw4wwGCXQyRP&open=AZ9bnDlsWw4wwGCXQyRP&pullRequest=3896
if (id === undefined || id === null) {
return;
}
const idStr = stringifyId(id);
const edits = (uiEdits[idStr] = uiEdits[idStr] || {});
for (const propName in newProps) {
// Keep the original value from the first edit - that's the value
// the component started with after (re)hydration.
const originalVal =
propName in edits ? edits[propName][1] : component.props[propName];
edits[propName] = [newProps[propName], originalVal];
}
}

/*
* Move all recorded edits to the pending store, in memory (all a soft
* reload needs, since the js context survives) and in sessionStorage (for
* hard reloads, where it doesn't). Called by the Reloader just before it
* triggers a reload.
*/
export function snapshotReloadState() {
// Entries not yet re-applied since the last reload stay pending, so
// state isn't lost when reloads happen in quick succession.
pending = pending || {};
for (const idStr in uiEdits) {
const edits = uiEdits[idStr];
const pendingEdits = (pending[idStr] = pending[idStr] || {});
for (const propName in edits) {
const [newVal, originalVal] = edits[propName];
try {
pendingEdits[propName] = [
_stringify(newVal),
_stringify(originalVal)
];
} catch (e) {
// Unserializable value - drop this prop, keep the rest.
delete pendingEdits[propName];
}

Check warning on line 111 in dash/dash-renderer/src/reloadState.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ9bnDlsWw4wwGCXQyRQ&open=AZ9bnDlsWw4wwGCXQyRQ&pullRequest=3896
}
}
uiEdits = {};
try {
window.sessionStorage.setItem(storeKey(), JSON.stringify(pending));
} catch (e) {
// Quota exceeded or sessionStorage unavailable - a hard reload
// will lose state, but don't block the reload over it.
/* eslint-disable-next-line no-console */
console.warn('dash: failed to save state for hot reload.', e);
}
}

/*
* Merge pending recorded edits into an incoming layout (or sub-layout
* inserted by a callback). Returns the possibly-modified layout.
*/
export function applyReloadState(layout) {
if (pending === null) {
// Fresh js context: recover the snapshot a hard reload left in
// sessionStorage, if any.
pending = {};
try {
const stored = window.sessionStorage.getItem(storeKey());
if (stored) {
pending = JSON.parse(stored) || {};
}
} catch (e) {
// Unreadable snapshot - start fresh.
}

Check warning on line 141 in dash/dash-renderer/src/reloadState.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ9bnDlsWw4wwGCXQyRR&open=AZ9bnDlsWw4wwGCXQyRR&pullRequest=3896
}
// Always consume the stored snapshot so a manual browser refresh
// (which never writes one) starts from a clean slate.
try {
window.sessionStorage.removeItem(storeKey());
} catch (e) {
// sessionStorage unavailable - nothing to consume.
}

Check warning on line 149 in dash/dash-renderer/src/reloadState.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ9bnDlsWw4wwGCXQyRS&open=AZ9bnDlsWw4wwGCXQyRS&pullRequest=3896
if (isEmpty(pending)) {
return layout;
}
let layoutOut = layout;
crawlLayout(layout, (component, componentPath) => {
const id = component && component.props && component.props.id;

Check warning on line 155 in dash/dash-renderer/src/reloadState.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=AZ9bnDlsWw4wwGCXQyRT&open=AZ9bnDlsWw4wwGCXQyRT&pullRequest=3896
if (id === undefined || id === null) {
return;
}
const idStr = stringifyId(id);
const edits = pending[idStr];
if (!edits) {
return;
}
for (const propName in edits) {
let newVal, originalVal;
try {
newVal = _parse(edits[propName][0]);
originalVal = _parse(edits[propName][1]);
} catch (e) {
delete edits[propName];
continue;
}

Check warning on line 172 in dash/dash-renderer/src/reloadState.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ9bnDlsWw4wwGCXQyRU&open=AZ9bnDlsWw4wwGCXQyRU&pullRequest=3896
if (equals(component.props[propName], originalVal)) {
layoutOut = set(
lensPath(componentPath.concat(['props', propName])),
newVal,
layoutOut
);
// Re-record so the edit survives the next reload too.
recordReloadEdit(component, {[propName]: newVal});
}
// Either way this entry is settled: it was applied, or the
// initial value changed in the new code and the new code wins.
delete edits[propName];
}
if (isEmpty(edits)) {
delete pending[idStr];
}
});
return layoutOut;
}
Loading
Loading