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() == []