From c1f289ead2c1a0d74295a54584ea9a51b96d6dd2 Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Wed, 17 Dec 2025 16:07:27 -0600 Subject: [PATCH 1/4] feat: DH-21093: Make usePersistentState available to all panels --- packages/dashboard/src/DashboardLayout.tsx | 23 +++++++---- .../dashboard/src/DashboardPanelWrapper.tsx | 40 +++++++++++++++++-- .../src/container/ItemContainer.ts | 21 ++++++---- .../src/utils/ReactComponentHandler.tsx | 22 ++++++---- 4 files changed, 78 insertions(+), 28 deletions(-) diff --git a/packages/dashboard/src/DashboardLayout.tsx b/packages/dashboard/src/DashboardLayout.tsx index 79ae4774ae..5a0797ea6e 100644 --- a/packages/dashboard/src/DashboardLayout.tsx +++ b/packages/dashboard/src/DashboardLayout.tsx @@ -73,7 +73,7 @@ type DashboardLayoutProps = React.PropsWithChildren<{ emptyDashboard?: React.ReactNode; /** Component to wrap each panel with */ - panelWrapper?: ComponentType>; + panelWrapper?: ComponentType>; }>; /** @@ -89,7 +89,7 @@ export function DashboardLayout({ onLayoutInitialized = DEFAULT_CALLBACK, hydrate = hydrateDefault, dehydrate = dehydrateDefault, - panelWrapper = DashboardPanelWrapper, + panelWrapper, }: DashboardLayoutProps): JSX.Element { const dispatch = useDispatch(); const data = @@ -141,6 +141,14 @@ export function DashboardLayout({ */ const hasRef = canHaveRef(CType); + const innerElem = hasRef ? ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ) : ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); + // Props supplied by GoldenLayout const { glContainer, glEventHub } = props; const panelId = LayoutUtils.getIdFromContainer(glContainer); @@ -148,15 +156,14 @@ export function DashboardLayout({ {/* eslint-disable-next-line react/jsx-props-no-spreading */} - - {hasRef ? ( - // eslint-disable-next-line react/jsx-props-no-spreading - + + {PanelWrapperType == null ? ( + innerElem ) : ( // eslint-disable-next-line react/jsx-props-no-spreading - + {innerElem} )} - + ); diff --git a/packages/dashboard/src/DashboardPanelWrapper.tsx b/packages/dashboard/src/DashboardPanelWrapper.tsx index 1b76343103..fc16c06516 100644 --- a/packages/dashboard/src/DashboardPanelWrapper.tsx +++ b/packages/dashboard/src/DashboardPanelWrapper.tsx @@ -1,10 +1,42 @@ -import React, { type PropsWithChildren } from 'react'; +import { useCallback, useState, type PropsWithChildren } from 'react'; +import Log from '@deephaven/log'; +import { PersistentStateProvider } from './PersistentStateContext'; +import type { PanelProps } from './DashboardPlugin'; + +const log = Log.module('DashboardPanelWrapper'); export function DashboardPanelWrapper({ + glContainer, children, -}: PropsWithChildren): JSX.Element { - // eslint-disable-next-line react/jsx-no-useless-fragment - return <>{children}; +}: PropsWithChildren): JSX.Element { + const handleDataChange = useCallback( + (data: unknown) => { + glContainer.setPersistedState(data); + }, + [glContainer] + ); + + const { persistedState } = glContainer.getConfig(); + + // Use a state initializer so we can warn once if the persisted state is invalid + const [initialPersistedState] = useState(() => { + if (persistedState != null && !Array.isArray(persistedState)) { + log.warn( + `Persisted state is type ${typeof persistedState}. Expected array. Setting to empty array.` + ); + return []; + } + return persistedState ?? []; + }); + + return ( + + {children} + + ); } export default DashboardPanelWrapper; diff --git a/packages/golden-layout/src/container/ItemContainer.ts b/packages/golden-layout/src/container/ItemContainer.ts index d8c1c9cc42..8a0625614c 100644 --- a/packages/golden-layout/src/container/ItemContainer.ts +++ b/packages/golden-layout/src/container/ItemContainer.ts @@ -26,7 +26,10 @@ export default class ItemContainer< tab?: Tab; // This type is to make TS happy and allow ReactComponentConfig passed to container generic - _config: C & { componentState: Record }; + _config: C & { + componentState: Record; + persistedState?: unknown; + }; isHidden = false; @@ -192,21 +195,23 @@ export default class ItemContainer< } /** - * Merges the provided state into the current one + * Notifies the layout manager of a stateupdate * * @param state */ - extendState(state: string) { - this.setState($.extend(true, this.getState(), state)); + setState(state: Record) { + this._config.componentState = state; + this.parent.emitBubblingEvent('stateChanged'); } /** - * Notifies the layout manager of a stateupdate + * Sets persisted state for functional components + * which do not have lifecycle methods to hook into. * - * @param state + * @param state The state to persist */ - setState(state: Record) { - this._config.componentState = state; + setPersistedState(state: unknown) { + this._config.persistedState = state; this.parent.emitBubblingEvent('stateChanged'); } diff --git a/packages/golden-layout/src/utils/ReactComponentHandler.tsx b/packages/golden-layout/src/utils/ReactComponentHandler.tsx index d1f368a643..d49a5904eb 100644 --- a/packages/golden-layout/src/utils/ReactComponentHandler.tsx +++ b/packages/golden-layout/src/utils/ReactComponentHandler.tsx @@ -21,7 +21,7 @@ export type GLPanelProps = { export default class ReactComponentHandler { private _container: ItemContainer; - private _reactComponent: React.Component | null = null; + private _reactComponent: React.ReactInstance | null = null; private _portalComponent: React.ReactPortal | null = null; private _originalComponentWillUpdate: Function | null = null; private _initialState: unknown; @@ -87,18 +87,24 @@ export default class ReactComponentHandler { * * @param component The component instance created by the `ReactDOM.render` call in the `_render` method. */ - _gotReactComponent(component: React.Component) { + _gotReactComponent(component: React.ReactInstance) { if (!component) { return; } this._reactComponent = component; - this._originalComponentWillUpdate = - this._reactComponent.componentWillUpdate || function () {}; - this._reactComponent.componentWillUpdate = this._onUpdate.bind(this); - const state = this._container.getState(); - if (state) { - this._reactComponent.setState(state); + // Class components manipulate the lifecycle to hook into state changes + if ( + 'componentWillUpdate' in this._reactComponent && + this._reactComponent.componentWillUpdate != null + ) { + this._originalComponentWillUpdate = + this._reactComponent.componentWillUpdate; + this._reactComponent.componentWillUpdate = this._onUpdate.bind(this); + const state = this._container.getState(); + if (state) { + this._reactComponent.setState(state); + } } } From 47424bd8575024f445e0bf526d37f950e73d4eac Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Thu, 18 Dec 2025 14:31:25 -0600 Subject: [PATCH 2/4] Add test --- packages/dashboard/src/Dashboard.test.tsx | 108 +++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/packages/dashboard/src/Dashboard.test.tsx b/packages/dashboard/src/Dashboard.test.tsx index 9faf59937c..98284c8663 100644 --- a/packages/dashboard/src/Dashboard.test.tsx +++ b/packages/dashboard/src/Dashboard.test.tsx @@ -1,7 +1,22 @@ import React from 'react'; -import { render, type RenderResult } from '@testing-library/react'; +import { + act, + render, + type RenderResult, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ApiContext } from '@deephaven/jsapi-bootstrap'; +import { type LayoutManager } from '@deephaven/golden-layout'; import Dashboard, { type DashboardProps } from './Dashboard'; +import { usePersistentState } from './usePersistentState'; +import { useDashboardPanel } from './layout'; +import { + assertIsDashboardPluginProps, + type DashboardPluginComponentProps, +} from './DashboardPlugin'; +import PanelEvent from './PanelEvent'; const mockDispatch = jest.fn(); jest.mock('react-redux', () => ({ @@ -18,7 +33,7 @@ function makeDashboard({ layoutConfig, layoutSettings, onGoldenLayoutChange, - onLayoutConfigChange, + onLayoutConfigChange = jest.fn(), }: DashboardProps = {}): RenderResult { return render( @@ -39,3 +54,92 @@ function makeDashboard({ it('mounts and unmounts properly', () => { makeDashboard(); }); + +function TestComponent() { + const [state, setState] = usePersistentState('initial', { + type: 'test', + version: 1, + }); + + return ( + + ); +} + +TestComponent.displayName = 'TestComponent'; + +function TestPlugin(props: Partial) { + assertIsDashboardPluginProps(props); + + useDashboardPanel({ + componentName: TestComponent.displayName, + component: TestComponent, + supportedTypes: ['test'], + dashboardProps: props, + }); + return null; +} + +it('saves state with usePersistentState hook', async () => { + const user = userEvent.setup(); + const onLayoutConfigChange = jest.fn(); + let gl: LayoutManager | null = null; + const onGoldenLayoutChange = (newGl: LayoutManager) => { + gl = newGl; + }; + const { unmount } = makeDashboard({ + onGoldenLayoutChange, + onLayoutConfigChange, + children: , + }); + + act(() => + gl!.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test' }, + }) + ); + expect(screen.getByText('initial')).toBeInTheDocument(); + + const waitRAF = () => + act( + () => + new Promise(resolve => { + requestAnimationFrame(resolve); + }) + ); + + // Golden Layout throttles stateChanged events to once per RAF + // The test is running too fast and getting batched into one RAF + // so we need to wait for the initial stateChanged to fire + await waitRAF(); + + onLayoutConfigChange.mockClear(); + await user.click(screen.getByText('initial')); + await waitFor(async () => + expect(await screen.findByText('updated')).toBeInTheDocument() + ); + + // Need to wait here as well because the stateChanged event fires on the next frame + // which seems to happen after the unmount without this wait + await waitRAF(); + + act(() => unmount()); + + expect(onLayoutConfigChange).toHaveBeenCalledTimes(1); + + // Remount and verify state is restored + makeDashboard({ + layoutConfig: onLayoutConfigChange.mock.calls[0][0], + onLayoutConfigChange, + children: , + }); + + await waitFor(() => expect(screen.getByText('updated')).toBeInTheDocument()); +}); From 510d5d1a716608748739badabbed621d11378c60 Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Mon, 5 Jan 2026 10:16:10 -0600 Subject: [PATCH 3/4] Fix types --- packages/dashboard/src/Dashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dashboard/src/Dashboard.tsx b/packages/dashboard/src/Dashboard.tsx index 9ba3fd7d38..c4536e4c84 100644 --- a/packages/dashboard/src/Dashboard.tsx +++ b/packages/dashboard/src/Dashboard.tsx @@ -47,7 +47,7 @@ export type DashboardProps = { dehydrate?: PanelDehydrateFunction; /** Component to wrap each panel with */ - panelWrapper?: ComponentType; + panelWrapper?: ComponentType>; }; export function Dashboard({ From 4cdfc203db65e4300e87104e1d26c6f1661ad535 Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Mon, 5 Jan 2026 15:49:09 -0600 Subject: [PATCH 4/4] And this is why we have tests --- .../src/utils/ReactComponentHandler.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/golden-layout/src/utils/ReactComponentHandler.tsx b/packages/golden-layout/src/utils/ReactComponentHandler.tsx index d49a5904eb..710bb2852f 100644 --- a/packages/golden-layout/src/utils/ReactComponentHandler.tsx +++ b/packages/golden-layout/src/utils/ReactComponentHandler.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { Component } from 'react'; import ReactDOM from 'react-dom'; import $ from 'jquery'; import type ItemContainer from '../container/ItemContainer'; @@ -23,14 +23,20 @@ export default class ReactComponentHandler { private _reactComponent: React.ReactInstance | null = null; private _portalComponent: React.ReactPortal | null = null; - private _originalComponentWillUpdate: Function | null = null; + private _originalComponentWillUpdate: + | (( + nextProps: Readonly, + nextState: Readonly<{}>, + nextContext: any + ) => void) + | undefined = undefined; private _initialState: unknown; private _reactClass: React.ComponentClass; constructor(container: ItemContainer, state?: unknown) { this._reactComponent = null; this._portalComponent = null; - this._originalComponentWillUpdate = null; + this._originalComponentWillUpdate = undefined; this._container = container; this._initialState = state; this._reactClass = this._getReactClass(); @@ -94,10 +100,8 @@ export default class ReactComponentHandler { this._reactComponent = component; // Class components manipulate the lifecycle to hook into state changes - if ( - 'componentWillUpdate' in this._reactComponent && - this._reactComponent.componentWillUpdate != null - ) { + // Functional components can save data with the usePersistentState hook + if (this._reactComponent instanceof Component) { this._originalComponentWillUpdate = this._reactComponent.componentWillUpdate; this._reactComponent.componentWillUpdate = this._onUpdate.bind(this); @@ -124,12 +128,13 @@ export default class ReactComponentHandler { * Hooks into React's state management and applies the componentstate * to GoldenLayout */ - _onUpdate(nextProps: unknown, nextState: Record) { + _onUpdate(nextProps: Readonly, nextState: Record) { this._container.setState(nextState); this._originalComponentWillUpdate?.call( this._reactComponent, nextProps, - nextState + nextState, + undefined ); }