From 10bf248dd09a177cc73a62b3cb47987f682e699e Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 10 Jul 2026 13:56:57 -0400 Subject: [PATCH] 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() == []