From 10bf248dd09a177cc73a62b3cb47987f682e699e Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 10 Jul 2026 13:56:57 -0400 Subject: [PATCH 1/2] Add dev_tools_hot_reload_preserve_state: keep UI state across hot reloads When enabled, the renderer records UI-driven prop edits, set_props updates (clientside, serverside and websocket) and memory-type dcc.Store writes as [newVal, originalVal] pairs. Just before a hot reload - soft RELOAD dispatch or full page reload - they are snapshotted (in memory + sessionStorage), then re-applied to the incoming layout unless a prop's initial value changed in the reloaded code, in which case the new code wins. Unmatched edits stay pending so components inserted by callbacks (e.g. pages content) are restored too. The snapshot is only written when a hot reload fires and consumed on use, so a manual browser refresh still resets the app. Off by default; enable with dev_tools_hot_reload_preserve_state=True or DASH_HOT_RELOAD_PRESERVE_STATE=true. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 + dash/_configs.py | 1 + dash/dash-renderer/src/APIController.react.js | 7 +- dash/dash-renderer/src/actions/callbacks.ts | 24 +- dash/dash-renderer/src/actions/index.js | 10 +- .../src/components/core/Reloader.react.js | 7 + .../src/observers/executedCallbacks.ts | 14 +- .../src/observers/websocketObserver.ts | 3 +- dash/dash-renderer/src/reloadState.js | 191 +++++++++++ dash/dash-renderer/tests/reloadState.test.js | 202 ++++++++++++ dash/dash.py | 30 ++ .../test_hot_reload_preserve_state.py | 312 ++++++++++++++++++ 12 files changed, 796 insertions(+), 10 deletions(-) create mode 100644 dash/dash-renderer/src/reloadState.js create mode 100644 dash/dash-renderer/tests/reloadState.test.js create mode 100644 tests/integration/devtools/test_hot_reload_preserve_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e48552db5f..fe9b0b7e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dash/_configs.py b/dash/_configs.py index 0e1ab75505..fcb73dc296 100644 --- a/dash/_configs.py +++ b/dash/_configs.py @@ -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", diff --git a/dash/dash-renderer/src/APIController.react.js b/dash/dash-renderer/src/APIController.react.js index f5a6c2381b..c40ca3b4d0 100644 --- a/dash/dash-renderer/src/APIController.react.js +++ b/dash/dash-renderer/src/APIController.react.js @@ -17,6 +17,7 @@ import {computeGraphs} from './actions/dependencies'; 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'; @@ -158,10 +159,14 @@ function storeEffect(props, events, setErrorLoading) { 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) { + // Restore UI state saved just before a hot reload. + finalLayout = applyReloadState(finalLayout); + } dispatch( setPaths( computePaths( diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index e6604a2337..b3816d1768 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -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); @@ -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})); @@ -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) => { @@ -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 @@ -628,7 +640,7 @@ function handleServerside( } if (data.sideUpdate) { - dispatch(sideUpdate(data.sideUpdate, payload)); + dispatch(sideUpdate(data.sideUpdate, payload, true)); } if (data.progress) { @@ -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 diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js index e595f79f9a..953e128ff7 100644 --- a/dash/dash-renderer/src/actions/index.js +++ b/dash/dash-renderer/src/actions/index.js @@ -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')); @@ -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)); }; } diff --git a/dash/dash-renderer/src/components/core/Reloader.react.js b/dash/dash-renderer/src/components/core/Reloader.react.js index 5149558d71..3490224972 100644 --- a/dash/dash-renderer/src/components/core/Reloader.react.js +++ b/dash/dash-renderer/src/components/core/Reloader.react.js @@ -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) { @@ -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 ( diff --git a/dash/dash-renderer/src/observers/executedCallbacks.ts b/dash/dash-renderer/src/observers/executedCallbacks.ts index 85d97299bc..b31070ef1c 100644 --- a/dash/dash-renderer/src/observers/executedCallbacks.ts +++ b/dash/dash-renderer/src/observers/executedCallbacks.ts @@ -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 = { @@ -65,7 +66,18 @@ const observer: IStoreObserverDefinition = { // 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)( updateProps({ itempath, diff --git a/dash/dash-renderer/src/observers/websocketObserver.ts b/dash/dash-renderer/src/observers/websocketObserver.ts index 24dc5a39d4..ae21b551cb 100644 --- a/dash/dash-renderer/src/observers/websocketObserver.ts +++ b/dash/dash-renderer/src/observers/websocketObserver.ts @@ -104,7 +104,8 @@ export async function initializeWebSocket( updateProps({ props: processedProps, itempath: componentPath, - renderType: 'websocket' + renderType: 'websocket', + recordState: true }) as any ); diff --git a/dash/dash-renderer/src/reloadState.js b/dash/dash-renderer/src/reloadState.js new file mode 100644 index 0000000000..4605d992be --- /dev/null +++ b/dash/dash-renderer/src/reloadState.js @@ -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']; + +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' && + component.namespace === 'dash_core_components' && + (component.props.storage_type || 'memory') === 'memory' + ); +} + +export function recordReloadEdit(component, newProps) { + const id = component && component.props && component.props.id; + 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]; + } + } + } + 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. + } + } + // 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. + } + if (isEmpty(pending)) { + return layout; + } + let layoutOut = layout; + crawlLayout(layout, (component, componentPath) => { + const id = component && component.props && component.props.id; + 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; + } + 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; +} diff --git a/dash/dash-renderer/tests/reloadState.test.js b/dash/dash-renderer/tests/reloadState.test.js new file mode 100644 index 0000000000..f728fd38a6 --- /dev/null +++ b/dash/dash-renderer/tests/reloadState.test.js @@ -0,0 +1,202 @@ +import {expect} from 'chai'; +import {beforeEach, describe, it} from 'mocha'; + +import { + applyReloadState, + recordReloadEdit, + resetReloadState, + shouldRecordReloadEdit, + snapshotReloadState +} from '../src/reloadState'; + +const component = (id, props) => ({ + namespace: 'dash_core_components', + type: 'Input', + props: {id, ...props} +}); + +const layout = (aValue, bValue) => ({ + namespace: 'dash_html_components', + type: 'Div', + props: { + children: [ + component('a', {value: aValue}), + component('b', {value: bValue}) + ] + } +}); + +const childValue = (lay, i) => lay.props.children[i].props.value; + +describe('state preservation across hot reloads', () => { + beforeEach(() => { + resetReloadState(); + window.sessionStorage.clear(); + }); + + it('restores a recorded UI edit when the initial value is unchanged', () => { + recordReloadEdit(component('a', {value: 'A0'}), {value: 'A1'}); + snapshotReloadState(); + + const out = applyReloadState(layout('A0', 'B0')); + expect(childValue(out, 0)).to.equal('A1'); + // untouched component is left alone + expect(childValue(out, 1)).to.equal('B0'); + }); + + it('lets the new code win when the initial value changed', () => { + recordReloadEdit(component('a', {value: 'A0'}), {value: 'A1'}); + snapshotReloadState(); + + const out = applyReloadState(layout('CHANGED', 'B0')); + expect(childValue(out, 0)).to.equal('CHANGED'); + }); + + it('keeps the first original value across repeated edits', () => { + recordReloadEdit(component('a', {value: 'A0'}), {value: 'A1'}); + recordReloadEdit(component('a', {value: 'A1'}), {value: 'A2'}); + snapshotReloadState(); + + // the layout still starts at A0 - the latest edit is applied + const out = applyReloadState(layout('A0', 'B0')); + expect(childValue(out, 0)).to.equal('A2'); + }); + + it('applies each edit at most once', () => { + recordReloadEdit(component('a', {value: 'A0'}), {value: 'A1'}); + snapshotReloadState(); + + applyReloadState(layout('A0', 'B0')); + // e.g. a callback recreates the component later in the session + const out = applyReloadState(layout('A0', 'B0')); + expect(childValue(out, 0)).to.equal('A0'); + }); + + it('survives a fresh js context through sessionStorage', () => { + recordReloadEdit(component('a', {value: 'A0'}), {value: 'A1'}); + snapshotReloadState(); + + // hard reload: module state is gone, sessionStorage remains + resetReloadState(); + + const out = applyReloadState(layout('A0', 'B0')); + expect(childValue(out, 0)).to.equal('A1'); + }); + + it('consumes the sessionStorage snapshot on first apply', () => { + recordReloadEdit(component('a', {value: 'A0'}), {value: 'A1'}); + snapshotReloadState(); + applyReloadState(layout('A0', 'B0')); + + // manual browser refresh: fresh js context, no snapshot left + resetReloadState(); + + const out = applyReloadState(layout('A0', 'B0')); + expect(childValue(out, 0)).to.equal('A0'); + }); + + it('keeps unmatched edits pending for later layout chunks', () => { + recordReloadEdit(component('x', {value: 'X0'}), {value: 'X1'}); + snapshotReloadState(); + + // initial layout doesn't contain x (e.g. pages content) + applyReloadState(layout('A0', 'B0')); + + // a callback inserts it later + const chunk = applyReloadState({ + props: {children: component('x', {value: 'X0'})} + }); + expect(chunk.props.children.props.value).to.equal('X1'); + }); + + it('preserves state again on a reload following a restore', () => { + recordReloadEdit(component('a', {value: 'A0'}), {value: 'A1'}); + snapshotReloadState(); + applyReloadState(layout('A0', 'B0')); + + // second reload without touching the component again + snapshotReloadState(); + const out = applyReloadState(layout('A0', 'B0')); + expect(childValue(out, 0)).to.equal('A1'); + }); + + it('handles props that were not defined initially', () => { + recordReloadEdit(component('a', {}), {value: 'A1'}); + snapshotReloadState(); + + const out = applyReloadState(layout(undefined, 'B0')); + expect(childValue(out, 0)).to.equal('A1'); + }); + + it('ignores components without an id', () => { + recordReloadEdit( + {...component('a', {value: 'A0'}), props: {value: 'A0'}}, + { + value: 'A1' + } + ); + snapshotReloadState(); + + const out = applyReloadState(layout('A0', 'B0')); + expect(childValue(out, 0)).to.equal('A0'); + }); + + describe('shouldRecordReloadEdit', () => { + const store = storage_type => ({ + namespace: 'dash_core_components', + type: 'Store', + props: {id: 's', ...(storage_type ? {storage_type} : {})} + }); + + const rt = renderType => ({renderType}); + + it('records UI edits and clientside set_props on any component', () => { + const c = component('a', {}); + expect(shouldRecordReloadEdit(c, rt('internal'))).to.equal(true); + expect(shouldRecordReloadEdit(c, rt('clientsideApi'))).to.equal( + true + ); + expect(shouldRecordReloadEdit(c, rt('callback'))).to.equal(false); + expect(shouldRecordReloadEdit(c, rt('websocket'))).to.equal(false); + expect(shouldRecordReloadEdit(c, rt(undefined))).to.equal(false); + }); + + it('records server set_props flagged with recordState', () => { + const c = component('a', {}); + expect( + shouldRecordReloadEdit(c, { + renderType: 'callback', + recordState: true + }) + ).to.equal(true); + expect( + shouldRecordReloadEdit(c, { + renderType: 'websocket', + recordState: true + }) + ).to.equal(true); + }); + + it('records any write to a memory dcc.Store', () => { + expect( + shouldRecordReloadEdit(store('memory'), rt('callback')) + ).to.equal(true); + // memory is the default storage_type + expect(shouldRecordReloadEdit(store(), rt('callback'))).to.equal( + true + ); + expect(shouldRecordReloadEdit(store(), rt('websocket'))).to.equal( + true + ); + }); + + it('leaves local/session stores to their own persistence', () => { + expect( + shouldRecordReloadEdit(store('local'), rt('callback')) + ).to.equal(false); + expect( + shouldRecordReloadEdit(store('session'), rt('callback')) + ).to.equal(false); + }); + }); +}); diff --git a/dash/dash.py b/dash/dash.py index 36a12c6d73..75d0d41535 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1005,6 +1005,7 @@ def _config(self): # convert from seconds to msec as used by js `setInterval` "interval": int(self._dev_tools.hot_reload_interval * 1000), "max_retry": self._dev_tools.hot_reload_max_retry, + "preserve_state": self._dev_tools.hot_reload_preserve_state, } if self.validation_layout and not self.config.suppress_callback_exceptions: validation_layout = self.validation_layout @@ -1986,6 +1987,12 @@ def _setup_dev_tools(self, **kwargs): get_combined_config(attr, kwargs.get(attr, None), default=default) ) + dev_tools["hot_reload_preserve_state"] = get_combined_config( + "hot_reload_preserve_state", + kwargs.get("hot_reload_preserve_state", None), + default=False, + ) + dev_tools["disable_version_check"] = get_combined_config( "disable_version_check", kwargs.get("disable_version_check", None), @@ -2008,6 +2015,7 @@ def enable_dev_tools( # pylint: disable=too-many-branches dev_tools_disable_version_check: Optional[bool] = None, dev_tools_prune_errors: Optional[bool] = None, dev_tools_validate_callbacks: Optional[bool] = None, + dev_tools_hot_reload_preserve_state: Optional[bool] = None, first_run: bool = True, ) -> bool: """Activate the dev tools, called by `run`. If your application @@ -2027,6 +2035,7 @@ def enable_dev_tools( # pylint: disable=too-many-branches - 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 @@ -2069,6 +2078,15 @@ def enable_dev_tools( # pylint: disable=too-many-branches env: ``DASH_HOT_RELOAD_MAX_RETRY`` :type dev_tools_hot_reload_max_retry: int + :param dev_tools_hot_reload_preserve_state: Preserve UI state across + hot reloads: prop values edited in the browser (dropdown + selections, input values, active tab...), props set through + ``set_props`` and memory-type ``dcc.Store`` data are saved before + the reload and restored afterward, unless their initial value + changed in the reloaded code. Default False. + env: ``DASH_HOT_RELOAD_PRESERVE_STATE`` + :type dev_tools_hot_reload_preserve_state: bool + :param dev_tools_silence_routes_logging: Silence the route logging for the web server (werkzeug for Flask, hypercorn for Quart, uvicorn for FastAPI). Enabled with debugging by default because hot reload hash checks generate @@ -2105,6 +2123,7 @@ def enable_dev_tools( # pylint: disable=too-many-branches hot_reload_interval=dev_tools_hot_reload_interval, hot_reload_watch_interval=dev_tools_hot_reload_watch_interval, hot_reload_max_retry=dev_tools_hot_reload_max_retry, + hot_reload_preserve_state=dev_tools_hot_reload_preserve_state, silence_routes_logging=dev_tools_silence_routes_logging, disable_version_check=dev_tools_disable_version_check, prune_errors=dev_tools_prune_errors, @@ -2306,6 +2325,7 @@ def run( dev_tools_disable_version_check: Optional[bool] = None, dev_tools_prune_errors: Optional[bool] = None, dev_tools_validate_callbacks: Optional[bool] = None, + dev_tools_hot_reload_preserve_state: Optional[bool] = None, **flask_run_options, ): """Start the flask server in local mode, you should not run this on a @@ -2379,6 +2399,15 @@ def run( env: ``DASH_HOT_RELOAD_MAX_RETRY`` :type dev_tools_hot_reload_max_retry: int + :param dev_tools_hot_reload_preserve_state: Preserve UI state across + hot reloads: prop values edited in the browser (dropdown + selections, input values, active tab...), props set through + ``set_props`` and memory-type ``dcc.Store`` data are saved before + the reload and restored afterward, unless their initial value + changed in the reloaded code. Default False. + env: ``DASH_HOT_RELOAD_PRESERVE_STATE`` + :type dev_tools_hot_reload_preserve_state: bool + :param dev_tools_silence_routes_logging: Silence the route logging for the web server (werkzeug for Flask, hypercorn for Quart, uvicorn for FastAPI). Enabled with debugging by default because hot reload hash checks generate @@ -2439,6 +2468,7 @@ def run( dev_tools_disable_version_check, dev_tools_prune_errors, dev_tools_validate_callbacks, + dev_tools_hot_reload_preserve_state=dev_tools_hot_reload_preserve_state, ) # Evaluate the env variables at runtime diff --git a/tests/integration/devtools/test_hot_reload_preserve_state.py b/tests/integration/devtools/test_hot_reload_preserve_state.py new file mode 100644 index 0000000000..c46011ccc4 --- /dev/null +++ b/tests/integration/devtools/test_hot_reload_preserve_state.py @@ -0,0 +1,312 @@ +from selenium.common.exceptions import WebDriverException + +from dash._utils import generate_hash +from dash.testing.wait import until + +from dash import Dash, Input, Output, dcc, html, no_update, set_props + + +def make_app(): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Input(id="input-a", value="initial-a"), + dcc.Input(id="input-b", value="initial-b"), + dcc.Dropdown(id="dropdown", options=["a", "b", "c"], value="a"), + html.Div(id="out"), + html.Div(id="dd-out"), + ] + ) + + @app.callback(Output("out", "children"), Input("input-a", "value")) + def out(value): + return f"out: {value}" + + @app.callback(Output("dd-out", "children"), Input("dropdown", "value")) + def dd_out(value): + return f"dd: {value}" + + return app + + +hot_reload_settings = dict( + dev_tools_hot_reload=True, + dev_tools_ui=True, + dev_tools_serve_dev_bundles=True, + dev_tools_hot_reload_interval=0.1, + dev_tools_hot_reload_max_retry=100, +) + + +def soft_reload(app): + # Simulate the hash change the server produces when it restarts after + # a backend code edit. + _reload = app._hot_reload + with _reload.lock: + _reload.hash = generate_hash() + + +def hard_reload(app): + # Simulate a non-css asset change: hard=True with no css files makes + # the renderer do a full page reload. + _reload = app._hot_reload + with _reload.lock: + _reload.hash = generate_hash() + _reload.hard = True + + +def test_dvps001_soft_reload_preserves_ui_state(dash_duo): + app = make_app() + dash_duo.start_server( + app, dev_tools_hot_reload_preserve_state=True, **hot_reload_settings + ) + + dash_duo.wait_for_text_to_equal("#out", "out: initial-a") + + # Make some UI edits. + dash_duo.find_element("#input-a").send_keys("-edited") + dash_duo.wait_for_text_to_equal("#out", "out: initial-a-edited") + dash_duo.select_dcc_dropdown("#dropdown", "b") + dash_duo.wait_for_text_to_equal("#dd-out", "dd: b") + + # Soft reloads keep the js context - use a marker to prove the page + # itself did not reload. + dash_duo.driver.execute_script("window.someVar = 42;") + + # Change input-b's initial value in the "new code": the new value must + # win over any preserved state. + app.layout.children[1].value = "changed-in-code" + soft_reload(app) + + # The new layout is in: the reload really happened. + dash_duo.wait_for_text_to_equal("#input-b", "changed-in-code") + # It was a soft reload. + assert dash_duo.driver.execute_script("return window.someVar") == 42 + + # UI edits to unchanged-in-code props were restored, and the initial + # callbacks re-ran with the restored values. + dash_duo.wait_for_text_to_equal("#input-a", "initial-a-edited") + dash_duo.wait_for_text_to_equal("#out", "out: initial-a-edited") + dash_duo.wait_for_text_to_equal("#dd-out", "dd: b") + + assert dash_duo.get_logs() == [] + + +def test_dvps002_hard_reload_preserves_ui_state(dash_duo): + app = make_app() + dash_duo.start_server( + app, dev_tools_hot_reload_preserve_state=True, **hot_reload_settings + ) + + dash_duo.wait_for_text_to_equal("#out", "out: initial-a") + dash_duo.find_element("#input-a").send_keys("-edited") + dash_duo.wait_for_text_to_equal("#out", "out: initial-a-edited") + + dash_duo.driver.execute_script("window.someVar = 42;") + hard_reload(app) + + # The page fully reloaded: the js context is gone. + def some_var_gone(): + try: + return dash_duo.driver.execute_script("return window.someVar") is None + except WebDriverException: + return False + + until(some_var_gone, timeout=10) + + # But the UI edit survived through sessionStorage. + dash_duo.wait_for_text_to_equal("#input-a", "initial-a-edited") + dash_duo.wait_for_text_to_equal("#out", "out: initial-a-edited") + + # A manual browser refresh is the escape hatch: no snapshot is written, + # so the app comes back in its initial state. + dash_duo.driver.refresh() + dash_duo.wait_for_text_to_equal("#input-a", "initial-a") + dash_duo.wait_for_text_to_equal("#out", "out: initial-a") + + +def test_dvps003_preserve_state_off_by_default(dash_duo): + app = make_app() + dash_duo.start_server(app, **hot_reload_settings) + + dash_duo.wait_for_text_to_equal("#out", "out: initial-a") + dash_duo.find_element("#input-a").send_keys("-edited") + dash_duo.wait_for_text_to_equal("#out", "out: initial-a-edited") + + # Add a marker component so we can tell the reload completed. + app.layout.children.append(html.Div("v2", id="version")) + soft_reload(app) + + dash_duo.wait_for_text_to_equal("#version", "v2") + + # Without the flag, the soft reload resets UI state. + dash_duo.wait_for_text_to_equal("#out", "out: initial-a") + dash_duo.wait_for_text_to_equal("#input-a", "initial-a") + + +def test_dvps004_preserves_state_in_callback_generated_content(dash_duo): + # Components that only exist after an initial callback runs (e.g. pages + # content) don't appear in the initial layout: their state is restored + # when the callback inserts them. + app = Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div([html.Div(id="content"), html.Div(id="out")]) + + @app.callback(Output("content", "children"), Input("content", "id")) + def render_content(_): + return dcc.Input(id="inner-input", value="initial-inner") + + @app.callback(Output("out", "children"), Input("inner-input", "value")) + def out(value): + return f"out: {value}" + + dash_duo.start_server( + app, dev_tools_hot_reload_preserve_state=True, **hot_reload_settings + ) + + dash_duo.wait_for_text_to_equal("#out", "out: initial-inner") + dash_duo.find_element("#inner-input").send_keys("-edited") + dash_duo.wait_for_text_to_equal("#out", "out: initial-inner-edited") + + dash_duo.driver.execute_script("window.someVar = 42;") + soft_reload(app) + + # someVar still set: it was a soft reload; and the edit survived even + # though the component came from a callback, not the initial layout. + dash_duo.wait_for_text_to_equal("#inner-input", "initial-inner-edited") + dash_duo.wait_for_text_to_equal("#out", "out: initial-inner-edited") + assert dash_duo.driver.execute_script("return window.someVar") == 42 + + assert dash_duo.get_logs() == [] + + +def test_dvps005_preserves_memory_store_data(dash_duo): + # Data written to a memory-type dcc.Store by a callback lives only in + # the layout, so it must be preserved directly - it can't be recomputed + # here since the writing callback has prevent_initial_call=True. + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("Click", id="btn"), + dcc.Store(id="store"), + html.Div(id="out"), + ] + ) + + @app.callback( + Output("store", "data"), + Input("btn", "n_clicks"), + prevent_initial_call=True, + ) + def write(n_clicks): + return {"n": n_clicks} + + @app.callback(Output("out", "children"), Input("store", "data")) + def read(data): + return f"data: {data}" + + dash_duo.start_server( + app, dev_tools_hot_reload_preserve_state=True, **hot_reload_settings + ) + + dash_duo.wait_for_text_to_equal("#out", "data: None") + dash_duo.find_element("#btn").click() + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out", "data: {'n': 2}") + + dash_duo.driver.execute_script("window.someVar = 42;") + soft_reload(app) + + # The store data came back through the restore, not a recompute. + dash_duo.wait_for_text_to_equal("#out", "data: {'n': 2}") + assert dash_duo.driver.execute_script("return window.someVar") == 42 + + assert dash_duo.get_logs() == [] + + +def test_dvps006_preserves_clientside_set_props(dash_duo): + # Props set through window.dash_clientside.set_props count as UI state. + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("Click", id="btn"), + dcc.Input(id="target", value="initial"), + html.Div(id="out"), + ] + ) + + app.clientside_callback( + """ + function(n_clicks) { + window.dash_clientside.set_props( + 'target', {value: 'set-' + n_clicks}); + return window.dash_clientside.no_update; + } + """, + Output("btn", "style"), + Input("btn", "n_clicks"), + prevent_initial_call=True, + ) + + @app.callback(Output("out", "children"), Input("target", "value")) + def out(value): + return f"out: {value}" + + dash_duo.start_server( + app, dev_tools_hot_reload_preserve_state=True, **hot_reload_settings + ) + + dash_duo.wait_for_text_to_equal("#out", "out: initial") + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out", "out: set-1") + + dash_duo.driver.execute_script("window.someVar = 42;") + soft_reload(app) + + dash_duo.wait_for_text_to_equal("#target", "set-1") + dash_duo.wait_for_text_to_equal("#out", "out: set-1") + assert dash_duo.driver.execute_script("return window.someVar") == 42 + + assert dash_duo.get_logs() == [] + + +def test_dvps007_preserves_server_set_props(dash_duo): + # Props set through dash.set_props in a server callback count as UI + # state too. + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("Click", id="btn"), + dcc.Input(id="target", value="initial"), + html.Div(id="out"), + ] + ) + + @app.callback( + Output("btn", "style"), + Input("btn", "n_clicks"), + prevent_initial_call=True, + ) + def set_target(n_clicks): + set_props("target", {"value": f"set-{n_clicks}"}) + return no_update + + @app.callback(Output("out", "children"), Input("target", "value")) + def out(value): + return f"out: {value}" + + dash_duo.start_server( + app, dev_tools_hot_reload_preserve_state=True, **hot_reload_settings + ) + + dash_duo.wait_for_text_to_equal("#out", "out: initial") + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out", "out: set-1") + + dash_duo.driver.execute_script("window.someVar = 42;") + soft_reload(app) + + dash_duo.wait_for_text_to_equal("#target", "set-1") + dash_duo.wait_for_text_to_equal("#out", "out: set-1") + assert dash_duo.driver.execute_script("return window.someVar") == 42 + + assert dash_duo.get_logs() == [] From e4c31c75794710f5ba48b6c4c4663077c44edfed Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 27 Aug 2026 13:50:30 -0400 Subject: [PATCH 2/2] Scope preserved hot-reload state per app via a persisted end_id Switching between two apps served on the same URL could let one app's preserved UI edits (n_clicks included) be restored into the other, since the sessionStorage snapshot was keyed only by pathname. Reuse the per-page-load end_id for this: when preserve_state is on, persist end_id to disk keyed by the app path (platformdirs with a dependency-free fallback) so it stays stable across the reload but unique per app, then scope the sessionStorage snapshot by it. A different app gets a different end_id and so a different key, and can never consume another app's state. --- CHANGELOG.md | 2 +- dash/_hot_reload.py | 78 +++++++++++++++++++ dash/dash-renderer/src/APIController.react.js | 2 +- .../src/components/core/Reloader.react.js | 4 +- .../src/observers/executedCallbacks.ts | 5 +- dash/dash-renderer/src/reloadState.js | 18 +++-- dash/dash-renderer/tests/reloadState.test.js | 25 ++++++ dash/dash.py | 31 ++++++-- tests/unit/test_hot_reload_state.py | 66 ++++++++++++++++ 9 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 dash/_hot_reload.py create mode 100644 tests/unit/test_hot_reload_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 865b51349f..775370356a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +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. +- 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). diff --git a/dash/_hot_reload.py b/dash/_hot_reload.py new file mode 100644 index 0000000000..eab3e19737 --- /dev/null +++ b/dash/_hot_reload.py @@ -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 diff --git a/dash/dash-renderer/src/APIController.react.js b/dash/dash-renderer/src/APIController.react.js index c40ca3b4d0..f36016734d 100644 --- a/dash/dash-renderer/src/APIController.react.js +++ b/dash/dash-renderer/src/APIController.react.js @@ -165,7 +165,7 @@ function storeEffect(props, events, setErrorLoading) { ); if (config.hot_reload && config.hot_reload.preserve_state) { // Restore UI state saved just before a hot reload. - finalLayout = applyReloadState(finalLayout); + finalLayout = applyReloadState(finalLayout, config.end_id); } dispatch( setPaths( diff --git a/dash/dash-renderer/src/components/core/Reloader.react.js b/dash/dash-renderer/src/components/core/Reloader.react.js index 3490224972..b7de36e9a5 100644 --- a/dash/dash-renderer/src/components/core/Reloader.react.js +++ b/dash/dash-renderer/src/components/core/Reloader.react.js @@ -150,14 +150,14 @@ class Reloader extends React.Component { // or a component lib has been added/removed - // Must do a hard reload if (this.props.config.hot_reload.preserve_state) { - snapshotReloadState(); + 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(); + snapshotReloadState(this.props.config.end_id); } dispatch({type: 'RELOAD'}); } diff --git a/dash/dash-renderer/src/observers/executedCallbacks.ts b/dash/dash-renderer/src/observers/executedCallbacks.ts index 11802720e0..846b45a8db 100644 --- a/dash/dash-renderer/src/observers/executedCallbacks.ts +++ b/dash/dash-renderer/src/observers/executedCallbacks.ts @@ -98,7 +98,10 @@ const observer: IStoreObserverDefinition = { ) { // Restore UI state saved just before a hot reload to // components inserted by callbacks (e.g. pages content). - ({props} = applyReloadState({props})); + ({props} = applyReloadState( + {props}, + pathOr(undefined, ['config', 'end_id'], getState()) + )); } (dispatch as ThunkDispatch)( updateProps({ diff --git a/dash/dash-renderer/src/reloadState.js b/dash/dash-renderer/src/reloadState.js index 4605d992be..3b8377af44 100644 --- a/dash/dash-renderer/src/reloadState.js +++ b/dash/dash-renderer/src/reloadState.js @@ -24,7 +24,13 @@ 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}`; +// Scope the snapshot by the page's end_id, not just its path. When +// `preserve_state` is on the server persists a per-app end_id (stable across +// the reload, unique to the app - see dash/_hot_reload.py), so a different app +// served on the same URL gets a different key and can never restore this app's +// preserved state into itself. +const storeKey = endId => + `_dash_reload_state.${window.location.pathname}.${endId || ''}`; // Values are stored stringified so `undefined` (prop not present) survives // the sessionStorage round-trip distinctly from `null`. @@ -91,7 +97,7 @@ export function recordReloadEdit(component, newProps) { * hard reloads, where it doesn't). Called by the Reloader just before it * triggers a reload. */ -export function snapshotReloadState() { +export function snapshotReloadState(endId) { // Entries not yet re-applied since the last reload stay pending, so // state isn't lost when reloads happen in quick succession. pending = pending || {}; @@ -113,7 +119,7 @@ export function snapshotReloadState() { } uiEdits = {}; try { - window.sessionStorage.setItem(storeKey(), JSON.stringify(pending)); + window.sessionStorage.setItem(storeKey(endId), JSON.stringify(pending)); } catch (e) { // Quota exceeded or sessionStorage unavailable - a hard reload // will lose state, but don't block the reload over it. @@ -126,13 +132,13 @@ export function snapshotReloadState() { * Merge pending recorded edits into an incoming layout (or sub-layout * inserted by a callback). Returns the possibly-modified layout. */ -export function applyReloadState(layout) { +export function applyReloadState(layout, endId) { 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()); + const stored = window.sessionStorage.getItem(storeKey(endId)); if (stored) { pending = JSON.parse(stored) || {}; } @@ -143,7 +149,7 @@ export function applyReloadState(layout) { // Always consume the stored snapshot so a manual browser refresh // (which never writes one) starts from a clean slate. try { - window.sessionStorage.removeItem(storeKey()); + window.sessionStorage.removeItem(storeKey(endId)); } catch (e) { // sessionStorage unavailable - nothing to consume. } diff --git a/dash/dash-renderer/tests/reloadState.test.js b/dash/dash-renderer/tests/reloadState.test.js index f728fd38a6..48448a44c4 100644 --- a/dash/dash-renderer/tests/reloadState.test.js +++ b/dash/dash-renderer/tests/reloadState.test.js @@ -128,6 +128,31 @@ describe('state preservation across hot reloads', () => { expect(childValue(out, 0)).to.equal('A1'); }); + it('scopes the snapshot by end_id so another app cannot consume it', () => { + recordReloadEdit(component('a', {value: 'A0'}), {value: 'A1'}); + snapshotReloadState('app-A'); + + // hard reload into a *different* app served on the same URL + resetReloadState(); + const other = applyReloadState(layout('A0', 'B0'), 'app-B'); + // the foreign app's matching component is left untouched + expect(childValue(other, 0)).to.equal('A0'); + + // the snapshot is still there for the app that wrote it + resetReloadState(); + const same = applyReloadState(layout('A0', 'B0'), 'app-A'); + expect(childValue(same, 0)).to.equal('A1'); + }); + + it('restores across a hard reload of the same app (matching end_id)', () => { + recordReloadEdit(component('a', {value: 'A0'}), {value: 'A1'}); + snapshotReloadState('app-A'); + + resetReloadState(); + const out = applyReloadState(layout('A0', 'B0'), 'app-A'); + expect(childValue(out, 0)).to.equal('A1'); + }); + it('ignores components without an id', () => { recordReloadEdit( {...component('a', {value: 'A0'}), props: {value: 'A0'}}, diff --git a/dash/dash.py b/dash/dash.py index ff54a94923..0a4aaf6d95 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -57,6 +57,7 @@ ) from . import _callback from . import _callback_signing +from . import _hot_reload as _hot_reload_state from . import _get_paths from . import _dash_renderer from . import _validate @@ -1061,12 +1062,30 @@ def _config(self): # echoes it on every callback request; the server binds background # callback handles (cacheKey/job) to it so they cannot be forged or # replayed from another page load. See dash/_callback_signing.py. - end_id = gen_salt(24) - config["end_id"] = _callback_signing.sign( - self._get_signing_secret(), - _callback_signing.END_SCOPE, - end_id, - ) + def _mint_end_id(): + return _callback_signing.sign( + self._get_signing_secret(), + _callback_signing.END_SCOPE, + gen_salt(24), + ) + + if self._dev_tools.hot_reload and self._dev_tools.hot_reload_preserve_state: + # Hot-reload state preservation scopes its sessionStorage snapshot + # by end_id, so the token has to stay stable across the reload + # (which restarts the process) and unique to this app. Persist it + # to disk keyed by the app path so a different app served on the + # same URL can't consume this one's preserved state. + app_key = "|".join( + ( + os.path.abspath(sys.argv[0]) if sys.argv and sys.argv[0] else "", + getattr(self.server, "root_path", "") or "", + self.config.name or "", + self.config.requests_pathname_prefix or "", + ) + ) + config["end_id"] = _hot_reload_state.stable_end_id(app_key, _mint_end_id) + else: + config["end_id"] = _mint_end_id() if self._plotly_cloud is None: if os.getenv("DASH_ENTERPRISE_ENV") == "WORKSPACE": # Disable the placeholder button on workspace. diff --git a/tests/unit/test_hot_reload_state.py b/tests/unit/test_hot_reload_state.py new file mode 100644 index 0000000000..9eb441c184 --- /dev/null +++ b/tests/unit/test_hot_reload_state.py @@ -0,0 +1,66 @@ +import os + +from dash import _hot_reload + + +def _base(monkeypatch, tmp_path): + # Pin the token store to a temp dir so the test never touches the real + # user data dir. + monkeypatch.setattr(_hot_reload, "_base_dir", lambda: str(tmp_path)) + + +def test_stable_end_id_is_persisted_per_app_key(monkeypatch, tmp_path): + _base(monkeypatch, tmp_path) + + calls = [] + + def factory(value): + def _make(): + calls.append(value) + return value + + return _make + + first = _hot_reload.stable_end_id("app-A", factory("A-token")) + # A second run of the same app reads the persisted token back instead of + # minting a new one. + second = _hot_reload.stable_end_id("app-A", factory("A-token-2")) + + assert first == "A-token" + assert second == "A-token" + assert calls == ["A-token"] # factory only ran on the first (cache miss) + + +def test_stable_end_id_differs_between_apps(monkeypatch, tmp_path): + _base(monkeypatch, tmp_path) + + a = _hot_reload.stable_end_id("app-A", lambda: "A-token") + b = _hot_reload.stable_end_id("app-B", lambda: "B-token") + + assert a != b + assert a == "A-token" + assert b == "B-token" + + +def test_stable_end_id_falls_back_when_dir_unwritable(monkeypatch, tmp_path): + # Point at a path that cannot be created (a file where a dir is expected) + # so persistence fails; the freshly minted token is still returned. + blocker = tmp_path / "blocker" + blocker.write_text("not a dir") + monkeypatch.setattr(_hot_reload, "_base_dir", lambda: str(blocker / "sub")) + + minted = _hot_reload.stable_end_id("app-A", lambda: "fresh") + assert minted == "fresh" + + +def test_stable_end_id_hashes_key_into_filename(monkeypatch, tmp_path): + _base(monkeypatch, tmp_path) + + # An app key that is a filesystem path must not leak path separators into + # the token filename. + _hot_reload.stable_end_id("/abs/path/to/app.py|/", lambda: "tok") + + files = os.listdir(os.path.join(str(tmp_path), _hot_reload._SUBDIR)) + assert len(files) == 1 + assert files[0].endswith(".txt") + assert "/" not in files[0]