Skip to content
39 changes: 39 additions & 0 deletions plugins/ui/src/deephaven/ui/_internal/RenderContext.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,14 @@ class RenderContext:
A value that can be used to store arbitrary data for this context.
"""

_hook_setters: Dict[StateKey, Callable[[Any], None]]
"""
Cache of stable state setter functions keyed by hook index. The same setter
instance is returned on every render so it serializes to a stable callable id
for the client (a fresh setter each render would be assigned a new callable
id, causing the client to receive a new prop - e.g. `onChange` - every render).
"""

def __init__(self, root: RootRenderContextProtocol):
"""
Create a new render context.
Expand All @@ -285,6 +293,7 @@ def __init__(self, root: RootRenderContextProtocol):
self._is_mounted = True
self._is_dirty = True
self._cache = None
self._hook_setters = {}

def __del__(self):
logger.debug("Deleting context")
Expand Down Expand Up @@ -537,6 +546,35 @@ def update_state():
self._root.on_change(update_state)
self.mark_dirty()

def make_state_setter(self, key: StateKey) -> Callable[[Any], None]:
"""
Get a stable setter function for the given state key.

The same function instance is returned on every render for a given key.
This matters because callables are serialized to the client by object
identity - returning a fresh setter each render would be assigned a new
callable id and the client would see a new prop (e.g. `onChange`) on
every render, needlessly churning props and cancelling in-flight work
such as debounced input changes.

Args:
key: The state key (hook index) to get the setter for.

Returns:
A stable function that sets the state for the given key.
"""
setter = self._hook_setters.get(key)
if setter is None:

def set_value(new_value: Any) -> None:
# Set the value in the context state and trigger a re-render
logger.debug("use_state set_value called with %s", new_value)
self.queue_render(lambda: self.set_state(key, new_value))

self._hook_setters[key] = set_value
setter = set_value
return setter

def get_child_context(
self, key: ContextKey, fetch_only: bool = False
) -> "RenderContext":
Expand Down Expand Up @@ -712,6 +750,7 @@ def unmount(self) -> None:
self._collected_effects.clear()
self._collected_unmount_listeners.clear()
self._collected_contexts.clear()
self._hook_setters.clear()

@property
def cache(self) -> Any:
Expand Down
7 changes: 3 additions & 4 deletions plugins/ui/src/deephaven/ui/hooks/use_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,8 @@ def use_state(

value: T = context.get_state(hook_index)

def set_value(new_value: T | UpdaterFunction[T]):
# Set the value in the context state and trigger a re-render
logger.debug("use_state set_value called with %s", new_value)
context.queue_render(lambda: context.set_state(hook_index, new_value))
# Use a stable setter instance across renders so it serializes to a stable
# callable id for the client (see RenderContext.make_state_setter).
set_value = context.make_state_setter(hook_index)

return value, set_value
44 changes: 42 additions & 2 deletions plugins/ui/src/js/src/elements/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import React, { type Key, type ReactElement, useMemo } from 'react';
import React, {
type Key,
type ReactElement,
useCallback,
useMemo,
} from 'react';
import {
Tabs as DHCTabs,
type TabsProps,
Expand All @@ -9,6 +14,7 @@ import {
type TabListProps,
} from '@deephaven/components';
import { isElementOfType } from '@deephaven/react-hooks';
import { usePersistentState } from '@deephaven/plugin';
import { ensureArray } from '@deephaven/utils';
import classNames from 'classnames';
import { TabPanels } from './TabPanels';
Expand All @@ -32,6 +38,11 @@ type TabChild =
| ReactElement<TabListProps<TabProps>, typeof TabList<TabProps>>
| ReactElement<TabPanelsProps<TabProps>, typeof TabPanels>;

// The selection key type accepted by the underlying Spectrum Tabs
// (`string | number`). Note this is narrower than React's `Key`, which also
// includes `bigint`.
type TabSelectionKey = NonNullable<TabComponentProps['defaultSelectedKey']>;

function containsDuplicateKeys(childrenArray: JSX.Element[]) {
const keys = childrenArray.map(child => child.key);
return new Set(keys).size !== keys.length;
Expand Down Expand Up @@ -83,11 +94,36 @@ export function Tabs(props: TabComponentProps): JSX.Element {
onSelectionChange: onSelectionChangeProp,
onChange,
UNSAFE_className,
selectedKey,
defaultSelectedKey,
...otherTabProps
} = props;
const childrenArray = useMemo(() => ensureArray(children), [children]);

const onSelectionChange = onSelectionChangeProp ?? onChange;
// Persist the active tab so it is restored when the widget is rehydrated
// (e.g. after a page refresh). When the component is controlled by the server
// (`selectedKey` provided) the server remains the source of truth.
const [persistedKey, setPersistedKey] = usePersistentState<
TabSelectionKey | undefined
>(defaultSelectedKey, { type: 'UITabs', version: 1 });

const userOnSelectionChange = onSelectionChangeProp ?? onChange;

const onSelectionChange = useCallback(
(key: TabSelectionKey) => {
setPersistedKey(key);
userOnSelectionChange?.(key);
},
[setPersistedKey, userOnSelectionChange]
);

// When the server controls the selection, pass it straight through.
// Otherwise seed the (uncontrolled) default from the persisted value so the
// previously active tab is selected on rehydration.
const selectionProps =
selectedKey !== undefined
? { selectedKey }
: { defaultSelectedKey: persistedKey ?? defaultSelectedKey };

const tabPanelsOrLists = childrenArray.filter(
child =>
Expand Down Expand Up @@ -141,6 +177,8 @@ export function Tabs(props: TabComponentProps): JSX.Element {
onSelectionChange={onSelectionChange}
UNSAFE_className={classNames('dh-tabs', UNSAFE_className)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...selectionProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...otherTabProps}
>
{children}
Expand All @@ -153,6 +191,8 @@ export function Tabs(props: TabComponentProps): JSX.Element {
onSelectionChange={onSelectionChange}
UNSAFE_className={classNames('dh-tabs', UNSAFE_className)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...selectionProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...otherTabProps}
>
<TabList marginBottom="size-100">{tabListChildren}</TabList>
Expand Down
10 changes: 9 additions & 1 deletion plugins/ui/src/js/src/layout/ReactPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,15 @@ function ReactPanel({
}
LayoutUtils.openComponent({ root: parent, config });
log.debug('Opened panel', panelId, config);
} else if (openedMetadataRef.current !== metadata) {
} else if (
openedMetadataRef.current != null &&
openedMetadataRef.current !== metadata
) {
// The panel already exists and its metadata has changed, so bring it to
// the front of its stack. We intentionally skip this when there is no
// previously-seen metadata (`openedMetadataRef.current == null`), which
// is the case when a whole dashboard is being rehydrated - stealing the
// active item then would clobber the persisted active tab.
const contentItem = LayoutUtils.getContentItemInStack(
existingStack,
itemConfig
Expand Down
26 changes: 26 additions & 0 deletions plugins/ui/src/js/src/layout/usePanelManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,32 @@ describe('usePanelManager', () => {
},
});
});

it('accumulates state across sequential updates from different panels', () => {
const widget = makeWidget();
const onDataChange = jest.fn();
const { result } = renderHook(() =>
usePanelManager({ widget, onDataChange })
);

const panel1Data = [{ a: 1 }];
const panel2Data = [{ b: 2 }];

act(() => {
result.current.onDataChange('panel-1', panel1Data);
});
act(() => {
result.current.onDataChange('panel-2', panel2Data);
});

// The second panel's update must not drop the first panel's state
expect(onDataChange).toHaveBeenLastCalledWith({
panelStates: {
'panel-1': panel1Data,
'panel-2': panel2Data,
},
});
});
});

describe('metadata', () => {
Expand Down
26 changes: 20 additions & 6 deletions plugins/ui/src/js/src/layout/usePanelManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ export function usePanelManager({
// initialization function into `useRef` like you can with `useState`
const [widgetData] = useState<WidgetData>(() => structuredClone(initialData));

// Accumulates the latest state for every panel. `widgetData` only holds the
// initial data (used for rehydration lookups) and is never updated, so we
// cannot merge into it - doing so would make each panel's update drop the
// other panels' updates, and only the most recently updated panel would be
// persisted.
const panelStatesRef = useRef<Record<string, unknown[]>>({
...widgetData.panelStates,
});

// panelIds that are currently opened within this document. This list is tracked by the `onOpen`/`onClose` call on the `ReactPanelManager` from a child component.
// Note that the initial widget data provided will be the `panelIds` for this document to use; this array is what is actually opened currently.
const panelIds = useRef<string[]>([]);
Expand Down Expand Up @@ -100,14 +109,15 @@ export function usePanelManager({

const handleDataChange = useCallback(
(panelId: string, panelData: unknown[]) => {
panelStatesRef.current = {
...panelStatesRef.current,
[panelId]: panelData,
};
onDataChange({
panelStates: {
...widgetData.panelStates,
[panelId]: panelData,
},
panelStates: { ...panelStatesRef.current },
});
},
[onDataChange, widgetData]
[onDataChange]
);

/**
Expand All @@ -129,7 +139,11 @@ export function usePanelManager({
log.debug('Widget', id, 'closed all panels, triggering onClose');
onClose?.();
} else {
onDataChange({ ...widgetData, panelIds: [...panelIds.current] });
onDataChange({
...widgetData,
panelStates: { ...panelStatesRef.current },
panelIds: [...panelIds.current],
});
}
},
[isPanelsDirty, id, onClose, onDataChange, widgetData]
Expand Down
27 changes: 27 additions & 0 deletions plugins/ui/test/deephaven/ui/hooks/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,30 @@ def _test_state(value1: int = 1, value2: int = 2):
val1, set_val1, val2, set_val2 = result
self.assertEqual(val1, 3)
self.assertEqual(val2, 4)

def test_state_setter_is_stable(self):
from deephaven.ui.hooks import use_state

def _test_state():
_, set_value1 = use_state(1)
_, set_value2 = use_state(2)
return set_value1, set_value2

render_result = render_hook(_test_state)
result, rerender = itemgetter("result", "rerender")(render_result)
first_set1, first_set2 = result

# The setter instances must be stable across renders so that they
# serialize to a stable callable id for the client (otherwise the client
# would receive a new prop, e.g. onChange, on every render).
rerender()
second_set1, second_set2 = itemgetter("result")(render_result)
self.assertIs(first_set1, second_set1)
self.assertIs(first_set2, second_set2)

# Setting state and re-rendering should still return the same setters
first_set1(3)
rerender()
third_set1, third_set2 = itemgetter("result")(render_result)
self.assertIs(first_set1, third_set1)
self.assertIs(first_set2, third_set2)
1 change: 1 addition & 0 deletions tests/app.d/tests.app
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ file_17=ui_events.py
file_18=ui_memo.py
file_19=ui_combo_box.py
file_20=ui_multi_select.py
file_21=ui_dashboard_persistence.py
Loading
Loading